提示词场景库提示词场景库提示词场景库
提示词分类发现
提示词场景库·2026
关于/免责隐私条款
所有分类

智能体技能提示词

187 个提示词•0 订阅者
CODEOWNERS 覆盖与所有权检查器
技能

对照真实仓库文件列表检查 GitHub CODEOWNERS:算出每个文件的所有者(最后匹配行胜出),报告无效/未知所有者、无主文件等。

---
name: codeowners-coverage-and-ownership-checker
description: 对照真实仓库文件列表检查 GitHub CODEOWNERS:算出每个文件的所有者(最后匹配行胜出),报告无效/未知所有者、无主文件等。
---

# CODEOWNERS 覆盖与所有权检查器

你是「CODEOWNERS 覆盖与所有权检查器」技能助手。上方 description 为中文任务说明。下方保留原技能完整正文(含脚本与代码);执行时遵循中文说明与原文技术细节。

# CODEOWNERS Coverage and Ownership Checker

You help engineering teams trust their CODEOWNERS file. Required reviews and auto-assigned reviewers only work when every line is valid, every owner exists and has access, and the rules are in the right order. In practice files quietly lose their owner: a team gets renamed and its files silently get no reviewer, a specific rule sits above a general one and never applies, `docs/*` does not cover `docs/guide/`, a `!` negation copied from .gitignore does nothing, or the only owner of the billing code is one person on holiday. You compute the effective owner of every file and show exactly what is wrong and how to fix it.

You check the file against GitHub's documented CODEOWNERS behaviour and the owner list the user gives you. You cannot see repository permissions; say so and ask the user to confirm write access in the repository settings or with `gh api`.

## Files in this skill

- `scripts/codeowners_check.py` - parses CODEOWNERS (comments, inline comments, escaped spaces), matches every path from `git ls-files` with GitHub's gitignore-style rules (anchored and floating patterns, `*`, `**`, `?`, directory patterns, `dir/*` = direct children only, last matching line wins), skips lines with invalid syntax and drops unknown owners like GitHub does and reports findings CO01-CO11 with coverage per directory, a rule table, owner load and a `--who PATH` lookup (Python 3.8+ standard library only)
- `references/codeowners-syntax-and-matching.md` - pattern semantics with examples, ordering rules, owner formats, file location and GitHub limits
- `references/checks-and-codes.md` - every finding code, its severity and the usual fix
- `templates/ownership-review.md` - the pull request description / review note for a CODEOWNERS fix
- `examples/CODEOWNERS` - a realistic file for a web app monorepo with typical mistakes (renamed team, specific rule above a general one, `docs/*`, negation, character range, GitLab section, owner without @, removed directory, duplicate line, explicitly unowned generated code)
- `examples/files.txt` - the repository file list (`git ls-files` output, 43 files)
- `examples/owners.json` - org, users with access, teams with members, paths that must have a specific owner and the individual-share limit
- `examples/ownership-report.md` - the real checker output on the example

## Workflow

### 1. Collect the inputs
Ask for the CODEOWNERS file (from `.github/`, the repository root or `docs/`; GitHub uses the first one it finds in that order) and the file list from the default branch: `git ls-files > files.txt`. Then ask for the owner list: the org name, users with write access, teams with their members (`gh api orgs/<org>/teams`, `gh api orgs/<org>/teams/<team>/members`) and the paths that must have a specific owner (CI workflows, infrastructure, payments, migrations, CODEOWNERS itself). Write them into a JSON like `examples/owners.json`. Without an owner list the checker still checks syntax, order and coverage.

### 2. Run the checker
```
python3 scripts/codeowners_check.py examples/CODEOWNERS examples/files.txt --owners examples/owners.json
python3 scripts/codeowners_check.py .github/CODEOWNERS files.txt --owners owners.json --who services/api/billing/invoices.py
python3 scripts/codeowners_check.py .github/CODEOWNERS files.txt --json
python3 scripts/codeowners_check.py --self-test
```
Exit 0 = no high findings, 1 = high findings, 2 = bad input. Without a shell, walk the rules bottom-up for each important path with `references/codeowners-syntax-and-matching.md`: the first line from the bottom that matches is the owner.

### 3. Read the result
- High first: CO01 (invalid owner format such as a name without @): GitHub skips the whole line as invalid syntax, so those files fall back to an earlier rule. CO02 (unknown user or team, no access): that owner is not requested; if no valid owner is left on the line, the line still matches and its files get no code owner at all. CO03 high: files under a must-own path have no owner.
- Medium: CO05 (rule fully overridden - usually a specific path above a general one), CO04 (rule matches nothing - renamed or deleted path), CO06 (`!` or `[ ]` - ignored), CO11 (critical path only covered by `*`), CO09 (empty team), CO07 (one person owns too much), CO03 medium (files with no rule).
- Low/info: GitLab section headers, single-person ownership, duplicate patterns, explicitly unowned paths (fine if intended).
- Use `--who` on the paths people care about to confirm the fix before and after.

### 4. Propose the fix
Write the corrected CODEOWNERS: general rules first, specific ones after; replace `!` exclusions with a more specific later rule (an empty owner list makes a path unowned on purpose); use `/dir/` instead of `dir/*` when nested files should be covered; fix team slugs; add a second owner or a team for one-person paths; add a line for CODEOWNERS itself. Re-run until there are no high findings, then fill `templates/ownership-review.md`.

### 5. Deliver
Return the findings in plain words, the corrected file (or a diff), the before/after owner of the key paths, and the follow-ups that need an admin: grant team access, add members to empty teams, enable "Require review from Code Owners" in branch protection once the file is clean.

## Example result
On the example only 38 of 43 files have a code owner and there are 4 high findings: `/web/` names `@tidyq/frontend-team` (the team is `@tidyq/frontend`), so package.json, App.tsx, Home.tsx and Checkout.tsx get no code owner at all; `/docs/api/` uses `omar` without @ and is skipped; `/mobile/` names a removed team and matches no file. `/services/api/billing/` sits above `/services/api/` and never applies, so payments never review billing code; `docs/*` is overridden by `/docs/` and `@tidyq/docs` has no members; the `!` and `[a-z]` lines are ignored; Terraform modules and dependabot.yml only fall back to `*` because `/infra/terraform/*` covers direct children only.

## Rules

- Last matching line wins: always reason bottom-up, and say which line owns a path.
- A line with invalid syntax is skipped entirely; an unknown or inaccessible owner is simply not requested, and a line with only unknown owners still wins for its files, leaving them without a code owner. Never assume an earlier rule takes over in that case.
- Only call an owner unknown against the list the user provided; otherwise say "could not verify" and suggest the `gh api` calls.
- Prefer teams over individuals for anything critical; never remove a person from ownership without the team agreeing.
- Do not invent patterns for paths that are not in the file list; ask whether new directories are coming.
- Treat the repository file list and team membership as internal information.
FILE:references/codeowners-syntax-and-matching.md
# CODEOWNERS syntax and matching (GitHub)

## Where the file lives
GitHub looks for `CODEOWNERS` in `.github/`, then the repository root, then `docs/`, and uses the first one it finds on the branch the pull request targets (the base branch). Files over 3 MB are ignored. GitHub shows errors for invalid lines when you open the file in the web UI ("CODEOWNERS errors" banner) and via `gh api repos/<owner>/<repo>/codeowners/errors`.

## Line format
```
<pattern>  <owner> [<owner> ...]   # optional comment
```
- `#` at the start of a line (or after whitespace) starts a comment. Unlike .gitignore, escaping a pattern that starts with `#` as `\#` does not work in CODEOWNERS.
- Paths are case sensitive, even if your laptop's file system is not.
- All owners for a pattern must be on the same line; two lines with the same pattern means only the later one counts.
- Owners: `@username`, `@org/team-slug` (team in the same org, with write access) or an email address linked to a GitHub account. A bare name such as `omar` is invalid.
- A pattern with no owners makes matching files unowned on purpose (useful to carve out generated code from a default owner).
- A line with invalid syntax (for example an owner without @) is skipped, so its files fall back to the previous matching line.
- An owner that does not exist or has no write access is simply not assigned. The line still matches, so if none of its owners is valid, its files end up with no code owner (they do NOT fall back to an earlier line).

## Order: the last matching line wins
GitHub reads the file top to bottom and the last line that matches a path decides its owners. Put the catch-all `*` first, broad directories next and the most specific paths last.

```
*                       @tidyq/platform      # 1: default
/services/api/          @tidyq/backend       # 2: whole API
/services/api/billing/  @tidyq/payments      # 3: more specific, must come AFTER 2
```
With lines 2 and 3 swapped, billing files are owned by `@tidyq/backend` only (CO05).

## Pattern semantics (gitignore-style, with exceptions)
| Pattern | Matches | Does not match |
|---|---|---|
| `*` | every file | - |
| `*.js` | `app.js`, `web/lib/util.js` (any depth) | `app.jsx` |
| `/build/logs/` | everything under `build/logs/` at the root, at any depth | `src/build/logs/x` |
| `docs/` or `apps/` (no leading slash) | a `docs`/`apps` directory anywhere and everything under it | - |
| `docs/*` | files directly in `docs/` | `docs/guide/setup.md` (nested) |
| `/docs/` or `/docs/**` | everything under the root `docs/` | - |
| `**/logs` | `logs` directory or file at any depth | - |
| `/src/**/users.py` | `src/users.py`, `src/api/v2/users.py` | - |
| `README.md` | `README.md` at any depth | - |
| `/README.md` | only the root `README.md` | `sub/README.md` |
| `src/ap?.py` | `src/app.py` | `src/apps.py` |

Rules of thumb:
- A pattern with a slash at the start or in the middle is anchored to the repository root; one without (apart from a trailing slash) floats and matches at any depth.
- A trailing slash means "this directory and everything in it".
- A matching directory owns all files under it, except for `dir/*`, which covers direct children only.

## Not supported (the line is ignored or flagged)
- `!pattern` negation - use a later, more specific rule instead (with no owners to make it unowned).
- `[a-z]` character ranges - list the paths or use `*`.
- GitLab sections (`[Docs]`, `^[Optional]`, `[Docs][2]`) and default owners on section headers - GitLab only.

## Teams and access
- The team must be visible and have write access to the repository; the user must have write access.
- An empty team gets the review request but nobody is notified.
- Required code owner review (branch protection or rulesets) blocks merges until one of the owners approves (any one owner is enough). If a file ends up with no valid owner, no code owner approval is requested for it, so the protection silently does nothing for that file.

## Getting the inputs
```
git ls-files > files.txt
gh api orgs/<org>/teams --paginate -q '.[].slug'
gh api orgs/<org>/teams/<slug>/members --paginate -q '.[].login'
gh api repos/<owner>/<repo>/collaborators --paginate -q '.[] | select(.permissions.push) | .login'
gh api repos/<owner>/<repo>/codeowners/errors
```
FILE:references/checks-and-codes.md
# Checks and finding codes

| Code | Severity | Check | Typical fix |
|---|---|---|---|
| CO01 | high | owner is not `@user`, `@org/team` or an email (e.g. `omar`); pattern cannot be parsed | add the @ / fix the slug; the whole line is skipped until then |
| CO02 | high | user not in your users list, team not in your teams list, or team from another org (only with `--owners`); a second CO02 line says when no valid owner is left on a line, so its files get no code owner | fix the slug (the checker suggests a close team), give the team write access, or remove the owner |
| CO03 | high: unowned file under a `require_owner` path; medium: other files with no rule or only unknown owners (explicitly unowned files are CO10) | coverage | add a rule, or a catch-all `*` default |
| CO04 | medium | the pattern matches no file in the list | path renamed or deleted: update or remove the line |
| CO05 | medium | every file the rule matches is owned by later lines (last match wins) | move the specific rule below the general one |
| CO06 | medium: `!` negation, `[ ]` ranges (ignored by GitHub); low: GitLab section header | unsupported syntax | use a later more specific rule; empty owner list = unowned |
| CO07 | medium: one individual (not a team) is the effective owner of more than `max_individual_share` of files; low: files owned by a single individual and no team | bus factor | add a team or a second owner |
| CO08 | low | the same pattern appears twice; only the later line counts | merge the owners into one line |
| CO09 | medium | team has no members in your teams list | add members or use another team |
| CO10 | info | pattern with no owners: files intentionally unowned | fine if intended (generated code, vendored files) |
| CO11 | medium | files under a `require_owner` path are only owned by the catch-all `*` rule | add a specific rule for that path |

## Verdict
- **High findings**: fix before relying on "Require review from Code Owners".
- **Medium only**: the file works but some files go to the wrong people.
- **Healthy**: only low/info.

## Owners file (`--owners`, JSON)
| Key | Meaning |
|---|---|
| `org` | org name; teams from another org are flagged |
| `users` | logins with write access (without @) |
| `teams` | `{"org/slug": [members]}` |
| `require_owner` | patterns (same syntax) for paths that must have a specific owner |
| `max_individual_share` | share of files one person may own alone (default 0.4) |
| `ignore` | patterns for files to leave out (vendored code, lock files) |

## Limits of the check
- Repository permissions and team visibility are not checked - confirm with the `gh api` calls in the syntax reference or the "CODEOWNERS errors" banner.
- The file list is a snapshot of one branch; run it against the base branch of your pull requests.
FILE:templates/ownership-review.md
# CODEOWNERS fix: <repository>

**Branch:** <base branch>   **Checked:** <YYYY-MM-DD>   **Files:** <n>   **Owned before / after:** <x% / y%>

## What was wrong
| Line | Rule | Problem | Effect |
|---|---|---|---|
| <11> | `<pattern> <owners>` | <unknown team @org/frontend-team> | <web files fall back to @org/platform> |
| <6> | `<pattern>` | <overridden by line 7> | <payments never review billing> |

## Changes
```diff
<- /services/api/billing/    @org/payments
<  /services/api/            @org/backend
<+ /services/api/billing/    @org/payments>
```

## Who owns the key paths now
| Path | Before | After |
|---|---|---|
| <services/api/billing/invoices.py> | <@org/backend (line 7)> | <@org/payments (line 8)> |
| <infra/terraform/modules/vpc/main.tf> | <@org/platform (line 3, catch-all)> | <@org/sre> |

## Needs an admin
- [ ] <give @org/frontend write access / add members to @org/docs>
- [ ] <enable "Require review from Code Owners" on <branch> once this is merged>

## Check
`python3 scripts/codeowners_check.py .github/CODEOWNERS files.txt --owners owners.json` -> <0 high, n medium (explain the rest)>

*Matching follows GitHub's CODEOWNERS rules; repository permissions were checked with <gh api / settings page | not checked>.*
FILE:examples/CODEOWNERS
# CODEOWNERS for tidyq/tidyq-app (copied into .github/CODEOWNERS)
# Default owners for everything not matched below
*                         @tidyq/platform

# Backend
/services/api/billing/    @tidyq/payments
/services/api/            @tidyq/backend
/services/worker/         @tidyq/backend @marta-k

# Frontend
/web/                     @tidyq/frontend-team
/web/src/design-system/   @lena-s
*.css                     @tidyq/frontend

# Infrastructure
/infra/terraform/*        @tidyq/sre
/infra/k8s/               @tidyq/sre
!/infra/k8s/sandbox/

# CI and repository settings
/.github/workflows/       @tidyq/sre
/.github/CODEOWNERS       @tidyq/platform-leads

# Docs
[Documentation]
docs/*                    @tidyq/docs
/docs/                    @tidyq/docs
/docs/api/                omar

# Old mobile app (moved to tidyq/tidyq-mobile in 2026)
/mobile/                  @tidyq/mobile

# Data
/data/migrations/         @jonas-b
/data/migrations/         @jonas-b @tidyq/backend
/data/fixtures/[a-z]*     @tidyq/qa

# Generated client: owned by nobody on purpose
/web/src/generated/
FILE:examples/files.txt
.github/CODEOWNERS
.github/dependabot.yml
.github/workflows/ci.yml
.github/workflows/deploy.yml
.github/workflows/release.yml
README.md
package.json
docs/index.md
docs/getting-started.md
docs/api/endpoints.md
docs/api/auth.md
docs/runbooks/oncall.md
infra/terraform/main.tf
infra/terraform/variables.tf
infra/terraform/modules/vpc/main.tf
infra/terraform/modules/rds/main.tf
infra/k8s/api-deployment.yaml
infra/k8s/worker-deployment.yaml
infra/k8s/sandbox/debug-pod.yaml
services/api/app.py
services/api/routes/users.py
services/api/routes/orders.py
services/api/billing/invoices.py
services/api/billing/stripe_webhook.py
services/api/tests/test_routes.py
services/worker/main.py
services/worker/jobs/email.py
services/worker/jobs/reports.py
web/package.json
web/src/App.tsx
web/src/pages/Home.tsx
web/src/pages/Checkout.tsx
web/src/styles/main.css
web/src/design-system/Button.tsx
web/src/design-system/Modal.tsx
web/src/design-system/tokens.css
web/src/generated/api-client.ts
data/migrations/0041_add_orders.sql
data/migrations/0042_add_invoices.sql
data/fixtures/users.json
data/fixtures/orders.json
scripts/seed_db.py
scripts/rotate_keys.sh
FILE:examples/owners.json
{
  "org": "tidyq",
  "users": ["marta-k", "lena-s", "jonas-b", "omar-h", "priya-n", "sam-t"],
  "teams": {
    "tidyq/platform": ["priya-n", "sam-t"],
    "tidyq/platform-leads": ["priya-n"],
    "tidyq/backend": ["marta-k", "jonas-b", "omar-h"],
    "tidyq/payments": ["marta-k"],
    "tidyq/frontend": ["lena-s", "sam-t"],
    "tidyq/sre": ["priya-n", "sam-t"],
    "tidyq/docs": [],
    "tidyq/qa": ["omar-h"]
  },
  "require_owner": [".github/", "infra/", "services/api/billing/", "data/migrations/"],
  "max_individual_share": 0.25,
  "ignore": []
}
FILE:examples/ownership-report.md
# CODEOWNERS check: examples/CODEOWNERS

20 rule line(s), 43 file(s), 38 owned (88.4%), 5 unowned.

**Verdict: 4 high finding(s) - fix CODEOWNERS before relying on required reviews**

Findings: 4 high, 9 medium, 3 low, 1 info.

Lines whose owners are all unknown (they still match, so their files get no code owner): 11, 31. Unknown owners are left out of the owner columns below.

## Findings

- **HIGH CO01** line 28: invalid owner 'omar' (use @user, @org/team or an email) - GitHub flags the whole line as an error and skips it
- **HIGH CO02** line 11: unknown team @tidyq/frontend-team (did you mean @tidyq/frontend?) - GitHub will not request reviews from it
- **HIGH CO02** line 11: no valid owner left - its 4 file(s) get no code owner (they do not fall back to earlier lines)
- **HIGH CO02** line 31: unknown team @tidyq/mobile - GitHub will not request reviews from it
- **MEDIUM CO03** 4 file(s) have no code owner (no matching rule, or only unknown owners): web/package.json, web/src/App.tsx, web/src/pages/Home.tsx, web/src/pages/Checkout.tsx
- **MEDIUM CO04** line 31: '/mobile/' matches no file - path renamed or removed?
- **MEDIUM CO05** line 6: '/services/api/billing/' (2 file(s)) is fully overridden by later line(s) 7 - last match wins, put specific rules after general ones
- **MEDIUM CO05** line 26: 'docs/*' (2 file(s)) is fully overridden by later line(s) 27 - last match wins, put specific rules after general ones
- **MEDIUM CO05** line 34: '/data/migrations/' (2 file(s)) is fully overridden by later line(s) 35 - last match wins, put specific rules after general ones
- **MEDIUM CO06** line 18: '!/infra/k8s/sandbox/' uses ! negation, which CODEOWNERS does not support - GitHub ignores the line
- **MEDIUM CO06** line 36: '/data/fixtures/[a-z]*' uses [ ] character ranges, which CODEOWNERS does not support - GitHub ignores the line
- **MEDIUM CO09** line 26: team @tidyq/docs has no members - review requests go nowhere
- **MEDIUM CO11** 3 file(s) under paths that must have an owner only fall back to the catch-all '*' rule (no specific owner): .github/dependabot.yml, infra/terraform/modules/vpc/main.tf, infra/terraform/modules/rds/main.tf
- **LOW CO06** line 25: '[Documentation]' is a GitLab section header; GitHub treats it as an invalid line
- **LOW CO07** 2 file(s) have a single individual owner and no team (e.g. web/src/design-system/Button.tsx, web/src/design-system/Modal.tsx)
- **LOW CO08** line 35 repeats the pattern '/data/migrations/' of line 34 - only the later line counts
- **INFO CO10** line 39: '/web/src/generated/' has no owners - its 1 file(s) are intentionally unowned

## Coverage by top-level directory

| Directory | Files | Owned | Unowned | Effective owners |
|---|---|---|---|---|
| (root) | 2 | 2 | 0 | @tidyq/platform (2) |
| .github/ | 5 | 5 | 0 | @tidyq/sre (3), @tidyq/platform-leads (1), @tidyq/platform (1) |
| data/ | 4 | 4 | 0 | @jonas-b (2), @tidyq/backend (2), @tidyq/platform (2) |
| docs/ | 5 | 5 | 0 | @tidyq/docs (5) |
| infra/ | 7 | 7 | 0 | @tidyq/sre (5), @tidyq/platform (2) |
| scripts/ | 2 | 2 | 0 | @tidyq/platform (2) |
| services/ | 9 | 9 | 0 | @tidyq/backend (9), @marta-k (3) |
| web/ | 9 | 4 | 5 | @tidyq/frontend (2), @lena-s (2) |

## Rules

| Line | Pattern | Owners | Files matched | Effective for | Note |
|---|---|---|---|---|---|
| 3 | `*` | @tidyq/platform | 43 | 9 |  |
| 6 | `/services/api/billing/` | @tidyq/payments | 2 | 0 | CO05 |
| 7 | `/services/api/` | @tidyq/backend | 6 | 6 |  |
| 8 | `/services/worker/` | @tidyq/backend @marta-k | 3 | 3 |  |
| 11 | `/web/` | @tidyq/frontend-team | 9 | 4 | CO02 |
| 12 | `/web/src/design-system/` | @lena-s | 3 | 2 |  |
| 13 | `*.css` | @tidyq/frontend | 2 | 2 |  |
| 16 | `/infra/terraform/*` | @tidyq/sre | 2 | 2 |  |
| 17 | `/infra/k8s/` | @tidyq/sre | 3 | 3 |  |
| 18 | `!/infra/k8s/sandbox/` | (none) | - | - | CO06 |
| 21 | `/.github/workflows/` | @tidyq/sre | 3 | 3 |  |
| 22 | `/.github/CODEOWNERS` | @tidyq/platform-leads | 1 | 1 |  |
| 26 | `docs/*` | @tidyq/docs | 2 | 0 | CO05, CO09 |
| 27 | `/docs/` | @tidyq/docs | 5 | 5 |  |
| 28 | `/docs/api/` | omar | - | - | CO01 |
| 31 | `/mobile/` | @tidyq/mobile | 0 | 0 | CO02, CO04 |
| 34 | `/data/migrations/` | @jonas-b | 2 | 0 | CO05 |
| 35 | `/data/migrations/` | @jonas-b @tidyq/backend | 2 | 2 | CO08 |
| 36 | `/data/fixtures/[a-z]*` | @tidyq/qa | - | - | CO06 |
| 39 | `/web/src/generated/` | (none) | 1 | 1 | CO10 |

## Owner load (effective)

| Owner | Files | Share |
|---|---|---|
| @tidyq/backend | 11 | 26% |
| @tidyq/platform | 9 | 21% |
| @tidyq/sre | 8 | 19% |
| @tidyq/docs | 5 | 12% |
| @marta-k | 3 | 7% |
| @tidyq/frontend | 2 | 5% |
| @lena-s | 2 | 5% |
| @jonas-b | 2 | 5% |
| @tidyq/platform-leads | 1 | 2% |

## Unowned files

- `web/package.json` (line 11: only unknown owners)
- `web/src/App.tsx` (line 11: only unknown owners)
- `web/src/pages/Home.tsx` (line 11: only unknown owners)
- `web/src/pages/Checkout.tsx` (line 11: only unknown owners)
- `web/src/generated/api-client.ts` (explicitly unowned, line 39)

## Who owns

- services/api/billing/invoices.py: @tidyq/backend (line 7: /services/api/)
- infra/terraform/modules/vpc/main.tf: @tidyq/platform (line 3: *)
- web/src/pages/Checkout.tsx: no valid owner (line 11: /web/ @tidyq/frontend-team - unknown)

Matching follows GitHub CODEOWNERS rules (gitignore-style patterns, last matching line wins, `!` and `[ ]` unsupported). Owners are checked against your owners file only; confirm write access in the repository settings.
FILE:scripts/codeowners_check.py
#!/usr/bin/env python3
"""CODEOWNERS coverage and ownership checker (stdlib only, Python 3.8+).

Reads a GitHub CODEOWNERS file and the repository file list (`git ls-files` output), works out
the effective owner of every file with GitHub's rules (gitignore-style patterns, the LAST
matching line wins) and reports syntax errors, unknown owners, unowned files (also under paths
that must have an owner), rules that match nothing, rules that are fully overridden by later
lines, unsupported syntax, duplicates, empty teams and owner concentration.

Usage:
  codeowners_check.py CODEOWNERS FILES.txt [--owners OWNERS.json] [--who PATH]... [--json]
  codeowners_check.py --self-test
Exit: 0 = no high findings, 1 = high findings, 2 = bad input.
"""
import argparse, json, re, sys
from collections import Counter, OrderedDict

SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
USER_RE = re.compile(r"^@[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$")
TEAM_RE = re.compile(r"^@([A-Za-z0-9][A-Za-z0-9-]*)/([A-Za-z0-9][A-Za-z0-9._-]*)$")
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")


class InputError(Exception):
    pass


# ---------------------------------------------------------------- patterns
def pattern_regex(pat):
    """Return (compiled regex, dir_only, direct_children_only) for a CODEOWNERS pattern."""
    dir_only = pat.endswith("/")
    p = pat.rstrip("/")
    anchored = p.startswith("/") or "/" in p
    p = p.lstrip("/")
    direct_only = p.endswith("/*")
    out, i = "", 0
    while i < len(p):
        if p.startswith("**/", i):
            out += "(?:.*/)?"; i += 3
        elif p.startswith("/**", i) and i + 3 == len(p):
            out += "/.*"; i += 3
        elif p.startswith("**", i):
            out += ".*"; i += 2
        elif p[i] == "*":
            out += "[^/]*"; i += 1
        elif p[i] == "?":
            out += "[^/]"; i += 1
        elif p[i] == "\\" and i + 1 < len(p):
            out += re.escape(p[i + 1]); i += 2
        else:
            out += re.escape(p[i]); i += 1
    if not anchored:
        out = "(?:.*/)?" + out
    return re.compile("^" + out + "$"), dir_only, direct_only


def matches(compiled, path):
    rx, dir_only, direct_only = compiled
    parts = path.split("/")
    if not dir_only and rx.match(path):
        return True
    if direct_only:
        return False
    for k in range(1, len(parts)):           # a matching directory owns everything under it
        if rx.match("/".join(parts[:k])):
            return True
    return False


# ---------------------------------------------------------------- parsing
def parse_codeowners(text):
    rules, findings = [], []
    for n, raw in enumerate(text.splitlines(), 1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        if re.match(r"^\^?\[[^\]]+\](\[\d+\])?(\s+@.*)?$", line):
            findings.append(("low", "CO06", "line %d: '%s' is a GitLab section header; GitHub treats it as an invalid line" % (n, line), n))
            continue
        tokens = re.split(r"(?<!\\)\s+", line)
        pat, owners = tokens[0], []
        for t in tokens[1:]:
            if t.startswith("#"):
                break
            owners.append(t)
        rule = {"line": n, "pattern": pat, "owners": owners, "valid": True}
        if pat.startswith("!"):
            findings.append(("medium", "CO06", "line %d: '%s' uses ! negation, which CODEOWNERS does not support - GitHub ignores the line" % (n, pat), n))
            rule["valid"] = False
        elif "[" in pat or "]" in pat:
            findings.append(("medium", "CO06", "line %d: '%s' uses [ ] character ranges, which CODEOWNERS does not support - GitHub ignores the line" % (n, pat), n))
            rule["valid"] = False
        bad = [o for o in owners if not (USER_RE.match(o) or TEAM_RE.match(o) or EMAIL_RE.match(o))]
        if bad:
            findings.append(("high", "CO01", "line %d: invalid owner %s (use @user, @org/team or an email) - GitHub flags the whole line as an error and skips it" % (n, ", ".join("'%s'" % b for b in bad)), n))
            rule["valid"] = False
        if rule["valid"]:
            try:
                rule["rx"] = pattern_regex(pat)
            except re.error as e:
                findings.append(("high", "CO01", "line %d: pattern '%s' cannot be parsed (%s)" % (n, pat, e), n))
                rule["valid"] = False
        rules.append(rule)
    return rules, findings


def load_files(text):
    files = []
    for l in text.splitlines():
        l = l.strip()
        if l and not l.startswith("#"):
            files.append(l[2:] if l.startswith("./") else l.lstrip("/"))
    if not files:
        raise InputError("file list is empty (use: git ls-files > files.txt)")
    return files


# ---------------------------------------------------------------- analysis
def analyse(co_text, files_text, owners_cfg=None):
    owners_cfg = owners_cfg or {}
    allowed_keys = {"org", "users", "teams", "require_owner", "max_individual_share", "ignore"}
    unknown = set(owners_cfg) - allowed_keys
    if unknown:
        raise InputError("unknown key(s) in owners file: %s" % ", ".join(sorted(unknown)))
    rules, findings = parse_codeowners(co_text)
    if not rules:
        raise InputError("CODEOWNERS has no rules")
    files = load_files(files_text)
    ignore = [pattern_regex(p) for p in owners_cfg.get("ignore", [])]
    files = [f for f in files if not any(matches(c, f) for c in ignore)]
    valid = [r for r in rules if r["valid"]]
    eff, matched_by = {}, {r["line"]: [] for r in valid}
    for f in files:
        last = None
        for r in valid:
            if matches(r["rx"], f):
                matched_by[r["line"]].append(f)
                last = r
        eff[f] = last
    effective = Counter(r["line"] for r in eff.values() if r)

    # known owners
    org = owners_cfg.get("org")
    users = {u.lower().lstrip("@") for u in owners_cfg.get("users", [])}
    teams = {k.lower().lstrip("@"): v for k, v in owners_cfg.get("teams", {}).items()}
    have_list = bool(users or teams)
    reported, unknown_owners = set(), set()
    for r in valid:
        for o in r["owners"]:
            key = o.lower()
            m = TEAM_RE.match(o)
            problem = None
            if m and have_list:
                name = (m.group(1) + "/" + m.group(2)).lower()
                if org and m.group(1).lower() != org.lower():
                    problem = "team %s is not in org @%s" % (o, org)
                elif name not in teams:
                    close = [t for t in teams if t.split("/")[-1].startswith(m.group(2).lower()[:4])]
                    problem = "unknown team %s%s" % (o, (" (did you mean @%s?)" % close[0]) if close else "")
                elif not teams[name] and key not in reported:
                    findings.append(("medium", "CO09", "line %d: team %s has no members - review requests go nowhere" % (r["line"], o), r["line"])); reported.add(key)
            elif USER_RE.match(o) and have_list and key.lstrip("@") not in users:
                problem = "unknown user %s (not in the owners list / no write access)" % o
            if problem:
                unknown_owners.add(key)
                if key not in reported:
                    findings.append(("high", "CO02", "line %d: %s - GitHub will not request reviews from it" % (r["line"], problem), r["line"])); reported.add(key)
    for r in rules:
        r["ok"] = [o for o in r["owners"] if o.lower() not in unknown_owners]
    bad_lines = sorted(r["line"] for r in valid if r["owners"] and not r["ok"])
    for ln in [x for x in bad_lines if effective[x]]:
        findings.append(("high", "CO02", "line %d: no valid owner left - its %d file(s) get no code owner (they do not fall back to earlier lines)" % (ln, effective[ln]), ln))

    # dead, shadowed, duplicate rules
    seen = {}
    for r in valid:
        if r["pattern"] in seen:
            findings.append(("low", "CO08", "line %d repeats the pattern '%s' of line %d - only the later line counts" % (r["line"], r["pattern"], seen[r["pattern"]]), r["line"]))
        seen[r["pattern"]] = r["line"]
        if not matched_by[r["line"]]:
            findings.append(("medium", "CO04", "line %d: '%s' matches no file - path renamed or removed?" % (r["line"], r["pattern"]), r["line"]))
        elif effective[r["line"]] == 0:
            later = sorted({eff[f]["line"] for f in matched_by[r["line"]] if eff[f]})
            findings.append(("medium", "CO05", "line %d: '%s' (%d file(s)) is fully overridden by later line(s) %s - last match wins, put specific rules after general ones" % (r["line"], r["pattern"], len(matched_by[r["line"]]), ", ".join(map(str, later))), r["line"]))
        if not r["owners"]:
            findings.append(("info", "CO10", "line %d: '%s' has no owners - its %d file(s) are intentionally unowned" % (r["line"], r["pattern"], effective[r["line"]]), r["line"]))

    # coverage
    unowned = [f for f in files if not eff[f] or not eff[f]["ok"]]
    no_rule = [f for f in unowned if not eff[f]]
    req = [(p, pattern_regex(p)) for p in owners_cfg.get("require_owner", [])]
    crit = [f for f in unowned if any(matches(c, f) for _, c in req)]
    if crit:
        findings.append(("high", "CO03", "%d file(s) under paths that must have an owner are unowned: %s" % (len(crit), ", ".join(crit[:6]) + (" ..." if len(crit) > 6 else "")), 0))
    catch_all = [f for f in files if eff[f] and eff[f]["ok"] and eff[f]["pattern"] in ("*", "/*", "**", "/**")
                 and any(matches(c, f) for _, c in req)]
    if catch_all:
        findings.append(("medium", "CO11", "%d file(s) under paths that must have an owner only fall back to the catch-all '%s' rule (no specific owner): %s" % (len(catch_all), eff[catch_all[0]]["pattern"], ", ".join(catch_all[:6]) + (" ..." if len(catch_all) > 6 else "")), 0))
    rest = [f for f in unowned if f not in crit and (not eff[f] or eff[f]["owners"])]
    if rest:
        findings.append(("medium", "CO03", "%d file(s) have no code owner (no matching rule, or only unknown owners): %s" % (len(rest), ", ".join(rest[:6]) + (" ..." if len(rest) > 6 else "")), 0))

    # owner load
    load = Counter()
    for f in files:
        if eff[f]:
            for o in eff[f]["ok"]:
                load[o] += 1
    share = float(owners_cfg.get("max_individual_share", 0.4))
    for o, n in load.most_common():
        if USER_RE.match(o) and n / len(files) > share:
            findings.append(("medium", "CO07", "%s is the owner of %d of %d files (%.0f%%) as an individual - bus factor; add a team or a second owner" % (o, n, len(files), 100.0 * n / len(files)), 0))
    solo = [f for f in files if eff[f] and len(eff[f]["ok"]) == 1 and USER_RE.match(eff[f]["ok"][0])]
    if solo:
        findings.append(("low", "CO07", "%d file(s) have a single individual owner and no team (e.g. %s)" % (len(solo), ", ".join(solo[:3])), 0))

    findings.sort(key=lambda f: (SEV[f[0]], f[1], f[3]))
    return {"rules": rules, "files": files, "eff": eff, "matched_by": matched_by, "effective": effective,
            "unowned": unowned, "load": load, "findings": findings, "no_valid_owner_lines": bad_lines}


def who(res, path):
    r = res["eff"].get(path)
    if path not in res["eff"]:
        rx_hits = [x for x in res["rules"] if x["valid"] and matches(x["rx"], path)]
        r = rx_hits[-1] if rx_hits else None
    if not r:
        return "%s: no owner (no matching rule)" % path
    if r["owners"] and not r["ok"]:
        return "%s: no valid owner (line %d: %s %s - unknown)" % (path, r["line"], r["pattern"], " ".join(r["owners"]))
    return "%s: %s (line %d: %s)" % (path, " ".join(r["ok"]) or "explicitly unowned", r["line"], r["pattern"])


# ---------------------------------------------------------------- report
def report(res, title, whos=()):
    f, files = res["findings"], res["files"]
    cnt = Counter(x[0] for x in f)
    owned = len(files) - len(res["unowned"])
    L = ["# CODEOWNERS check: %s" % title, ""]
    L.append("%d rule line(s), %d file(s), %d owned (%.1f%%), %d unowned." % (len(res["rules"]), len(files), owned, 100.0 * owned / len(files), len(res["unowned"])))
    L.append("")
    L.append("**Verdict: %s**" % ("%d high finding(s) - fix CODEOWNERS before relying on required reviews" % cnt["high"] if cnt["high"] else ("review the medium findings" if cnt["medium"] else "CODEOWNERS looks healthy")))
    L.append("")
    L.append("Findings: %d high, %d medium, %d low, %d info." % (cnt["high"], cnt["medium"], cnt["low"], cnt["info"]))
    L.append("")
    if res["no_valid_owner_lines"]:
        L.append("Lines whose owners are all unknown (they still match, so their files get no code owner): %s. Unknown owners are left out of the owner columns below." % ", ".join(map(str, res["no_valid_owner_lines"])))
        L.append("")
    L += ["## Findings", ""]
    for s, c, msg, _ in f:
        L.append("- **%s %s** %s" % (s.upper(), c, msg))
    L += ["", "## Coverage by top-level directory", "", "| Directory | Files | Owned | Unowned | Effective owners |", "|---|---|---|---|---|"]
    dirs = OrderedDict()
    for p in files:
        d = p.split("/")[0] + "/" if "/" in p else "(root)"
        dirs.setdefault(d, []).append(p)
    for d, ps in sorted(dirs.items()):
        un = [p for p in ps if p in res["unowned"]]
        own = Counter(o for p in ps if res["eff"][p] for o in res["eff"][p]["ok"])
        L.append("| %s | %d | %d | %d | %s |" % (d, len(ps), len(ps) - len(un), len(un), ", ".join("%s (%d)" % kv for kv in own.most_common()) or "-"))
    L += ["", "## Rules", "", "| Line | Pattern | Owners | Files matched | Effective for | Note |", "|---|---|---|---|---|---|"]
    notes = {}
    for s, c, msg, ln in f:
        if ln:
            notes.setdefault(ln, []).append(c)
    for r in res["rules"]:
        m = len(res["matched_by"].get(r["line"], [])) if r["valid"] else "-"
        e = res["effective"].get(r["line"], 0) if r["valid"] else "-"
        L.append("| %d | `%s` | %s | %s | %s | %s |" % (r["line"], r["pattern"], " ".join(r["owners"]) or "(none)", m, e, ", ".join(sorted(set(notes.get(r["line"], [])))) or ""))
    L += ["", "## Owner load (effective)", "", "| Owner | Files | Share |", "|---|---|---|"]
    for o, n in res["load"].most_common():
        L.append("| %s | %d | %.0f%% |" % (o, n, 100.0 * n / len(files)))
    if res["unowned"]:
        L += ["", "## Unowned files", ""]
        for p in res["unowned"]:
            e = res["eff"][p]
            L.append("- `%s`%s" % (p, "" if not e else (" (explicitly unowned, line %d)" % e["line"] if not e["owners"] else " (line %d: only unknown owners)" % e["line"])))
    if whos:
        L += ["", "## Who owns", ""]
        for p in whos:
            L.append("- " + who(res, p))
    L += ["", "Matching follows GitHub CODEOWNERS rules (gitignore-style patterns, last matching line wins, `!` and `[ ]` unsupported). Owners are checked against your owners file only; confirm write access in the repository settings."]
    return "\n".join(L) + "\n"


def to_json(res):
    return {"files": len(res["files"]), "unowned": res["unowned"], "no_valid_owner_lines": res["no_valid_owner_lines"],
            "owners": {p: (res["eff"][p]["ok"] if res["eff"][p] else []) for p in res["files"]},
            "rules": [{"line": r["line"], "pattern": r["pattern"], "owners": r["owners"], "valid": r["valid"],
                       "matched": len(res["matched_by"].get(r["line"], [])), "effective": res["effective"].get(r["line"], 0)} for r in res["rules"]],
            "load": dict(res["load"]),
            "findings": [{"severity": s, "code": c, "message": m, "line": ln} for s, c, m, ln in res["findings"]]}


# ---------------------------------------------------------------- self-test
def self_test():
    tests = []
    def t(name, cond):
        tests.append((name, bool(cond)))
    def owner(co, f, path, cfg=None):
        r = analyse(co, f, cfg)
        e = r["eff"][path]
        return e["ok"] if e else None
    def codes(co, f, cfg=None):
        return [(x[0], x[1]) for x in analyse(co, f, cfg)["findings"]]
    F = "README.md\nsrc/app.py\nsrc/api/routes.py\nsrc/api/v2/users.py\ndocs/index.md\ndocs/guide/setup.md\nweb/app.js\nweb/lib/util.js\n.github/workflows/ci.yml\n"
    t("* matches everything", owner("* @a", F, "src/api/v2/users.py") == ["@a"])
    t("last matching line wins", owner("* @a\n/src/ @b", F, "src/app.py") == ["@b"] and owner("* @a\n/src/ @b", F, "README.md") == ["@a"])
    t("*.js matches at any depth", owner("*.js @js", F, "web/lib/util.js") == ["@js"])
    t("/docs/ owns nested files", owner("/docs/ @d", F, "docs/guide/setup.md") == ["@d"])
    t("docs/* does not own nested files", owner("docs/* @d", F, "docs/guide/setup.md") is None and owner("docs/* @d", F, "docs/index.md") == ["@d"])
    t("unanchored dir name 'api/' matches anywhere", owner("api/ @x", F, "src/api/v2/users.py") == ["@x"])
    t("** in the middle", owner("/src/**/users.py @u", F, "src/api/v2/users.py") == ["@u"])
    t("anchored /README.md only at root", owner("/README.md @r", "README.md\nsub/README.md\n", "sub/README.md") is None)
    t("? matches one character", owner("src/ap?.py @q", F, "src/app.py") == ["@q"])
    t("no owners = explicitly unowned", owner("* @a\n/web/", F, "web/app.js") == [] and ("info", "CO10") in codes("* @a\n/web/", F))
    t("invalid owner -> CO01 high, line skipped", ("high", "CO01") in codes("* @a\n/src/ alice", F) and owner("* @a\n/src/ alice", F, "src/app.py") == ["@a"])
    t("negation -> CO06 medium", ("medium", "CO06") in codes("* @a\n!/src/", F))
    t("[ ] range -> CO06 medium", ("medium", "CO06") in codes("* @a\n/src/[ab]* @b", F))
    t("GitLab section -> CO06 low", ("low", "CO06") in codes("[Docs]\n* @a", F))
    cfg = {"org": "acme", "users": ["a", "b"], "teams": {"acme/web": ["a"], "acme/empty": []}}
    t("unknown team -> CO02 high, files get no owner (no fallback)", ("high", "CO02") in codes("* @a\n/web/ @acme/webb", F, cfg) and owner("* @a\n/web/ @acme/webb", F, "web/app.js", cfg) == [] and ("medium", "CO03") in codes("* @a\n/web/ @acme/webb", F, cfg))
    t("unknown owner dropped, valid co-owner kept", owner("* @a\n/web/ @acme/webb @b", F, "web/app.js", cfg) == ["@b"])
    t("team of other org -> CO02", ("high", "CO02") in codes("* @other/web", F, cfg))
    t("unknown user -> CO02", ("high", "CO02") in codes("* @zed", F, cfg))
    t("empty team -> CO09", ("medium", "CO09") in codes("* @a\n/web/ @acme/empty", F, cfg))
    t("email owner accepted", ("high", "CO01") not in codes("* dev@acme.io", F))
    t("dead rule -> CO04", ("medium", "CO04") in codes("* @a\n/legacy/ @b", F))
    t("shadowed rule -> CO05", ("medium", "CO05") in codes("/src/api/ @b\n/src/ @a", F))
    t("partially overridden is not CO05", ("medium", "CO05") not in codes("/src/ @a\n/src/api/ @b", F))
    t("duplicate pattern -> CO08", ("low", "CO08") in codes("* @a\n/src/ @b\n/src/ @c", F))
    t("unowned file -> CO03 medium", ("medium", "CO03") in codes("/src/ @a", F))
    t("require_owner path unowned -> CO03 high", ("high", "CO03") in codes("/src/ @acme/web", F, dict(cfg, require_owner=[".github/"])))
    t("required path only owned by '*' -> CO11", ("medium", "CO11") in codes("* @a\n/src/ @b", F, {"require_owner": [".github/"]}))
    t("dead rule with unknown owner still -> CO04", ("medium", "CO04") in codes("* @a\n/legacy/ @acme/zz", F, cfg))
    t("individual owner concentration -> CO07", ("medium", "CO07") in codes("* @a", F, {"max_individual_share": 0.5}))
    t("ignore list removes files", len(analyse("* @a", F, {"ignore": ["docs/"]})["files"]) == 7)
    r = analyse("* @a\n/src/ @b", F)
    t("--who reports line", who(r, "src/api/routes.py") == "src/api/routes.py: @b (line 2: /src/)")
    t("./ prefix stripped, .github kept", analyse("* @a", "./.github/x.yml\n")["files"] == [".github/x.yml"])
    t("report has verdict and coverage table", "**Verdict:" in report(r, "t") and "## Coverage by top-level directory" in report(r, "t"))
    try:
        analyse("* @a", F, {"orgs": "x"}); ok = False
    except InputError:
        ok = True
    t("unknown owners-file key rejected", ok)
    for name, ok in tests:
        print(("PASS  " if ok else "FAIL  ") + name)
    passed = sum(ok for _, ok in tests)
    print("%d/%d passed" % (passed, len(tests)))
    return 0 if passed == len(tests) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Check a GitHub CODEOWNERS file against the repository file list.")
    ap.add_argument("codeowners", nargs="?")
    ap.add_argument("files", nargs="?", help="file list, e.g. git ls-files > files.txt")
    ap.add_argument("--owners", help="JSON with org, users, teams, require_owner, max_individual_share, ignore")
    ap.add_argument("--who", action="append", default=[], help="show the effective owner of a path (repeatable)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.codeowners or not a.files:
        ap.print_usage(sys.stderr)
        return 2
    try:
        co = open(a.codeowners, encoding="utf-8").read()
        fl = open(a.files, encoding="utf-8").read()
        cfg = None
        if a.owners:
            try:
                cfg = json.load(open(a.owners, encoding="utf-8"))
            except json.JSONDecodeError as e:
                raise InputError("owners file is not valid JSON: %s" % e)
        res = analyse(co, fl, cfg)
    except (OSError, InputError) as e:
        print("error: %s" % e, file=sys.stderr)
        return 2
    if a.json:
        print(json.dumps(to_json(res), indent=2))
    else:
        sys.stdout.write(report(res, a.codeowners, a.who))
    return 1 if any(x[0] == "high" for x in res["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonSecurityDevOps+2
F@f
0
销售佣金发放计算器
技能

根据薪酬方案与交易 CSV 计算某周期销售佣金:按交易类型计配额、拆分单、含加速器的分层费率(边际或固定)等。

---
name: sales-commission-payout-calculator
description: 根据薪酬方案与交易 CSV 计算某周期销售佣金:按交易类型计配额、拆分单、含加速器的分层费率(边际或固定)等。
---

# 销售佣金发放计算器

你是「销售佣金发放计算器」技能助手。上方 description 为中文任务说明。下方保留原技能完整正文(含脚本与代码);执行时遵循中文说明与原文技术细节。

# Sales Commission Payout Calculator

You help sales ops, finance and founders pay commissions that are right the first time. Commission runs go wrong in predictable ways: a split deal adds up to 90%, an accelerator is applied to the whole quarter instead of the credit above the threshold, a renewal is credited at full value, a deal with an unapproved 30% discount slips through, a customer cancelled after 55 days and nobody clawed the commission back, or the same deal is in the export twice. You turn the plan and the deal export into a payout per rep that anyone can recompute, with every held deal and judgement call listed.

You apply the plan the user gives you. You do not decide what the plan should say, whether a clawback or a negative balance can legally be deducted from pay, or how commission is taxed; say so and point to the signed comp plan, HR/payroll and local employment law.

## Files in this skill

- `scripts/commission_calc.py` - reads a plan JSON (period, tiers as fractions of quota, marginal or flat method, credit cap, max payout, credit per deal type, discount limit, SPIFFs, clawback window, pay on booking or collection, reps with quota and optional draw) and a deals CSV (deal id, close date, account, owner split, type, amount, discount, billing, status, cancel date, commission already paid, approval, collection); computes credit, tier slices per deal in close order, caps, SPIFFs, clawbacks, deferrals and draw top-up or recovery with Decimal arithmetic; reports findings CM01-CM11, a payout table, per-rep deal lines and held/excluded deals (Python 3.8+ standard library only)
- `references/plan-mechanics.md` - tiers, marginal vs flat, caps, credit rules, splits, SPIFFs, clawbacks, draws, timing, with worked numbers
- `references/checks-and-codes.md` - every finding code, its severity and what to do about it, and the input fields
- `templates/commission-statement.md` - the statement each rep receives and signs off
- `examples/comp-plan.json` - an AE plan for October 2026: 4 tiers (5/8/12/15%), credit cap 200%, max payout 8,000, renewals at 50% credit, 20% discount limit, annual-prepay SPIFF, 90-day clawback, three reps, one with a recoverable draw
- `examples/deals.csv` - 15 rows: normal deals, a 60/40 split, a renewal, an approved and an unapproved big discount, a split that adds up to 90%, a deal for an unknown rep with a thousands separator, two cancellations (inside and outside the clawback window), a September deal and a duplicate row
- `examples/payout-report.md` - the real calculator output on the example

## Workflow

### 1. Collect the plan and the deals
Ask for the comp plan document (or the relevant page) and turn it into a plan JSON like `examples/comp-plan.json`. Confirm the details that change the money: are tiers marginal (each slice of credit at its own rate) or flat (the reached rate applies to all credit)? Is attainment measured on bookings, ACV or margin? How are renewals and expansions credited? Is there a credit cap or a payout cap? When is commission earned: at booking or when the customer pays? How long is the clawback window and does it cover the full commission? Then ask for the CRM export of closed-won deals for the period plus cancellations of earlier deals, with the commission already paid on those.

### 2. Run the calculator
```
python3 scripts/commission_calc.py examples/comp-plan.json examples/deals.csv
python3 scripts/commission_calc.py plan.json deals.csv --rep ana
python3 scripts/commission_calc.py plan.json deals.csv --json
python3 scripts/commission_calc.py --self-test
```
Exit 0 = no high findings, 1 = high findings (deals held that need a decision), 2 = bad input. Without a shell, follow `references/plan-mechanics.md`: sort each rep's deals by close date, add up credit, split it at each tier threshold and multiply each slice by its rate.

### 3. Read the result
- High first: CM01 (unknown rep, bad amount or date, unknown deal type) and CM02 (split not 100%). These deals are held - get the answer from sales ops before payroll.
- Medium: CM05 (discount over the limit without approval - held), CM04 (duplicate row), CM08 (clawback applied - check the amount paid), CM11 (clawbacks bigger than earnings - negative balance).
- Low/info: deals from other periods (CM03), cancellations outside the window, caps (CM06), accelerators (CM07), deferrals (CM09), draws (CM10).
- Check one rep by hand against the per-deal rate slices before sending anything.

### 4. Resolve and re-run
For every held deal record the decision (correct split, approval found, rep assignment) in the CSV and re-run. Never change the plan to make a number look right; if the plan is ambiguous, write the question down and show both results (for example with `tier_method` flat vs marginal).

### 5. Deliver
Return the payout table for approval, a statement per rep from `templates/commission-statement.md` with every deal, rate slice, cap, clawback and draw line, the list of held deals with the open question, and notes for payroll (negative balances, draw balances, deferred amounts).

## Example result
On the example October payouts total 9,500.00 EUR, with 3 deals held. Ana Ruiz reaches 158.7% of her 60,000 quota (renewal Delta Foods credited at 50%), earns 8,280.00 on marginal tiers plus 400.00 in annual-prepay SPIFFs and is capped at the 8,000.00 maximum. Ben Okafor earns 690.00 but the Ironclad deal cancelled 55 days after closing claws back 960.00, so he is paid 0 and carries -270.00 forward. Chloe Martin earns 1,050.00, below her 1,500.00 recoverable draw, so the draw tops up 450.00 and her balance rises to 1,050.00. Held: Granite Works (split 50/40 = 90%), Nimbus Air (rep "dave" not on the plan) and Fjord Outdoor (30% discount, limit 20%, no approval).

## Rules

- Follow the signed plan; when the plan and the data disagree, hold the deal and ask - never guess a split, a rep or an approval.
- Show the arithmetic: every payout must be traceable to deal credit, tier slices and adjustments.
- Apply tiers in close-date order and say whether the plan is marginal or flat.
- Treat clawbacks and negative balances carefully: state what the plan allows and point to HR/payroll and local law before deducting from pay.
- Commission data is confidential: share each rep only their own statement.
- This is a calculation aid, not legal, tax or payroll advice.
FILE:references/plan-mechanics.md
# Plan mechanics with worked numbers

All examples: quota 10,000; tiers 5% up to 50% of quota, 8% up to 100%, 12% above.

## Quota credit
- Credit = deal amount x credit rate for the deal type (`deal_type_credit`, e.g. new 1.0, expansion 1.0, renewal 0.5) x the rep's split share.
- Use the measure the plan names (bookings, first-year ACV, TCV, margin) and export that column as `amount`.
- Attainment = total credit / quota.

## Marginal tiers (default)
Each slice of credit is paid at the rate of the band it falls in, in close-date order.
- 12,000 credit: 5,000 x 5% = 250, 5,000 x 8% = 400, 2,000 x 12% = 240 -> 890.
- Two deals of 6,000: the first earns 5,000 x 5% + 1,000 x 8% = 330, the second 4,000 x 8% + 2,000 x 12% = 560. The total is the same in any order; the per-deal split depends on close order, so keep close dates accurate.

## Flat (retroactive) tiers
The rate of the tier reached applies to all credit: 12,000 x 12% = 1,440. Flat tiers create big jumps at each threshold (9,999 credit earns 799.92, 10,000 earns 1,200 under a 8%/12% flat plan) and invite deal timing games; check the plan wording ("on all bookings" vs "on bookings above").

## Accelerators and decelerators
- An accelerator is simply a higher rate above 100% (12-15% here). A decelerator is a lower rate above a cap point, modelled as a final tier with a lower rate.

## Caps
- Credit cap (`credit_cap_attainment`, e.g. 2.0): credit above 200% of quota earns 0%.
- Payout cap (`max_payout`): commission + SPIFFs for the period cannot exceed this amount. Many plans avoid caps; if yours has one, say so in the statement.

## Splits
- `owner_split` like `ana:60;ben:40` must add up to 100%. Each rep gets their share of credit and SPIFFs. Overlay roles (SE, partner manager) that get credit on top of 100% need their own plan file.

## SPIFFs
- Fixed bonuses for deals matching all `when` fields (for example `type=new` and `billing=annual`). Split with the deal.

## Discount limits
- Deals with `discount_pct` above `max_discount_pct` need `approved=yes`; otherwise the credit is held (`unapproved_discount: hold`) or paid with a finding (`pay`).

## Clawbacks
- A deal closed in an earlier period and cancelled in this period within `clawback.window_days` of closing claws back `share` x the commission paid on it (column `commission_paid`; if missing it is estimated at the first tier rate and flagged).
- Cancelled after the window: no clawback, noted.
- Closed and cancelled in the same period: never credited.
- If clawbacks exceed earnings the payout is 0 and the negative balance is carried forward (CM11). Whether it may be deducted from future pay depends on the plan and local law.

## Booking vs collection
- `pay_on: booking`: commission is paid in the close period.
- `pay_on: collection`: credit counts toward attainment now, but commission (and SPIFF) on deals with `collected` not yes is deferred until the customer pays. Deferred amounts are listed; pay them in the period the cash arrives.

## Draws
- Recoverable draw: the rep is paid at least the draw; if earnings are lower, the top-up is added to the draw balance; later surpluses above the draw repay that balance first.
  - Draw 1,500, balance 600, earned 1,050 -> paid 1,500, balance 1,050.
  - Next month earned 2,900 -> 2,900 - 1,500 = 1,400 available, 1,050 recovered, paid 1,850, balance 0.
- Non-recoverable draw: a guaranteed minimum; no balance.

## Order of operations used by the calculator
1. Credit per deal (type credit x split) for won deals closed in the period, minus held and excluded deals.
2. Tier slices per deal in close order (marginal) or the reached rate on all credit (flat), with the credit cap.
3. + SPIFFs, then the payout cap.
4. - Deferred (pay on collection), - clawbacks, + carry-in.
5. Draw top-up or recovery; negative results become a carry-forward.
FILE:references/checks-and-codes.md
# Checks and finding codes

| Code | Severity | Check | What to do |
|---|---|---|---|
| CM01 | high: unknown rep, missing rep, bad amount/date, deal type with no credit rate; medium: zero or negative amount | data needed to pay the deal | fix the CRM record or add the rep/type to the plan; held deals are not paid |
| CM02 | high | owner_split shares add up to 100% | get the split confirmed by sales ops / the deal desk |
| CM03 | low | won deal closed outside the period | make sure it was paid in its own period; late-booked deals need a manual adjustment |
| CM04 | medium | the same deal_id appears twice | the second row is ignored; fix the export |
| CM05 | medium | discount above `max_discount_pct` without `approved=yes` | find the approval or keep the credit on hold |
| CM06 | info | credit cap or payout cap applied | mention it on the statement |
| CM07 | info | accelerator tier reached | check the threshold maths once by hand |
| CM08 | medium: clawback applied (estimated if commission_paid missing); low: cancelled after the window; info: closed and cancelled in this period | cancellations | confirm the amount paid originally |
| CM09 | low | commission deferred until collection | pay it in the period the cash arrives |
| CM10 | info | draw top-up or recovery, with the new balance | carry the balance into next period's plan file |
| CM11 | medium | clawbacks exceed earnings: payout 0, negative carry-forward | check the plan and local law before deducting |

## Verdict
- **High findings**: deals are held; resolve before payroll. The payable total shown excludes held deals.
- **Ready for approval**: nothing blocks the run; review medium findings with sales ops.

## Plan JSON keys
`plan_name, period{start, end, label}, currency, tier_method (marginal|flat), tiers[{up_to, rate}] (fractions; last tier without up_to), credit_cap_attainment, max_payout, deal_type_credit{type: rate}, max_discount_pct, unapproved_discount (hold|pay), spiffs[{name, when{column: value}, amount}], clawback{window_days, share}, pay_on (booking|collection), reps{id: {name, quota, draw{amount, recoverable, balance}, carry_forward}}`.

## Deals CSV columns
`deal_id, close_date, account, owner_split (ana or ana:60;ben:40), type, amount, discount_pct, billing, status (won|cancelled), cancel_date, commission_paid, approved (yes/no), collected (yes/no)`. Thousands separators (12,500) and decimal commas (1.234,50) are accepted. Extra columns can be used in SPIFF `when` conditions.
FILE:templates/commission-statement.md
# Commission statement: <rep name>

**Period:** <October 2026>   **Plan:** <plan name, version/date signed>   **Quota:** <60,000.00 EUR>
**Prepared by:** <name, sales ops>   **Date:** <YYYY-MM-DD>

## Summary
| | Amount |
|---|---|
| Quota credit | <95,200.00> (<158.7%> of quota) |
| Commission (tiers) | <8,280.00> |
| SPIFFs | <400.00> |
| Cap | <-680.00> (max payout <8,000.00>) |
| Clawbacks | <-0.00> |
| Deferred to collection | <0.00> |
| Draw top-up / recovery | <0.00> |
| **Payout this period** | **<8,000.00>** |
| Balance carried forward | <0.00> |

## Deals
| Deal | Account | Closed | Type | Credit | Rate slices | Commission | SPIFF |
|---|---|---|---|---|---|---|---|
| <D-1001> | <Acme Logistics> | <2026-10-03> | <new> | <18,000.00> | <18,000.00 x 5.0%> | <900.00> | <200.00> |

## Adjustments
- <Clawback D-0950 cancelled 55 days after close: -960.00>
- <Held: D-1006 Fjord Outdoor, discount 30% awaiting approval - will be paid in the period it is approved>

## How it was calculated
<Marginal tiers 5% to 50% of quota, 8% to 100%, 12% to 150%, 15% above, applied in close-date order; renewals credited at 50%; credit cap 200%; max payout 8,000.>

Please review by <date>. Questions or disputes: <contact>. Disputes must name the deal and the line.

*Calculated from the plan and CRM data listed above. The signed compensation plan governs; this statement is not tax or legal advice.*
FILE:examples/comp-plan.json
{
  "plan_name": "Northlake Software AE plan FY2026",
  "period": {"start": "2026-10-01", "end": "2026-10-31", "label": "October 2026"},
  "currency": "EUR",
  "tier_method": "marginal",
  "tiers": [
    {"up_to": 0.5, "rate": 0.05},
    {"up_to": 1.0, "rate": 0.08},
    {"up_to": 1.5, "rate": 0.12},
    {"rate": 0.15}
  ],
  "credit_cap_attainment": 2.0,
  "max_payout": 8000,
  "deal_type_credit": {"new": 1.0, "expansion": 1.0, "renewal": 0.5},
  "max_discount_pct": 20,
  "unapproved_discount": "hold",
  "spiffs": [
    {"name": "Annual prepay on new logo", "when": {"type": "new", "billing": "annual"}, "amount": 200}
  ],
  "clawback": {"window_days": 90, "share": 1.0},
  "pay_on": "booking",
  "reps": {
    "ana": {"name": "Ana Ruiz", "quota": 60000},
    "ben": {"name": "Ben Okafor", "quota": 50000},
    "chloe": {"name": "Chloe Martin", "quota": 40000, "draw": {"amount": 1500, "recoverable": true, "balance": 600}}
  }
}
FILE:examples/deals.csv
deal_id,close_date,account,owner_split,type,amount,discount_pct,billing,status,cancel_date,commission_paid,approved,collected
D-1001,2026-10-03,Acme Logistics,ana,new,18000,10,annual,won,,,,yes
D-1002,2026-10-08,Brightwave Media,ana:60;ben:40,expansion,12000,0,monthly,won,,,,yes
D-1003,2026-10-14,Corvid Health,ana,new,30000,15,annual,won,,,,no
D-1004,2026-10-21,Delta Foods,ana,renewal,24000,5,annual,won,,,,yes
D-1005,2026-10-28,Evergreen Bank,ana,new,28000,25,monthly,won,,,yes,no
D-1006,2026-10-10,Fjord Outdoor,ben,new,22000,30,annual,won,,,,yes
D-1007,2026-10-17,Granite Works,ben:50;chloe:40,new,15000,10,monthly,won,,,,yes
D-1008,2026-10-24,Harbor Clinics,ben,expansion,9000,0,monthly,won,,,,yes
D-0950,2026-08-12,Ironclad Security,ben,new,16000,10,annual,cancelled,2026-10-06,960,,
D-0871,2026-05-20,Juniper Retail,ben,new,14000,5,monthly,cancelled,2026-10-15,700,,
D-1009,2026-10-09,Kestrel Labs,chloe,new,11000,10,annual,won,,,,yes
D-1010,2026-10-30,Lumen Studio,chloe,new,6000,0,monthly,won,,,,no
D-0990,2026-09-29,Meridian Travel,chloe,new,8000,5,monthly,won,,,,yes
D-1009,2026-10-09,Kestrel Labs,chloe,new,11000,10,annual,won,,,,yes
D-1012,2026-10-19,Nimbus Air,dave,new,"12,500",10,annual,won,,,,yes
FILE:examples/payout-report.md
# Commission payouts: Northlake Software AE plan FY2026, October 2026

Period 2026-10-01 to 2026-10-31, marginal tiers (5.0% up to 50.0% of quota, 8.0% up to 100.0% of quota, 12.0% up to 150.0% of quota, 15.0% above), pay on booking, credit cap 200.0% of quota, max payout 8,000.00. Source: examples/deals.csv.

**Verdict: 2 high finding(s), 3 deal(s) held - resolve them before payroll; total payable now 9,500.00 EUR**

Findings: 2 high, 4 medium, 2 low, 3 info. Figures follow the plan file you provided; the signed comp plan and local pay law win if they differ.

## Payouts

| Rep | Quota | Credit | Attainment | Tier | Commission | SPIFFs | Cap | Clawbacks | Deferred | Draw | Payout |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Ana Ruiz | 60,000.00 | 95,200.00 | 158.7% | 4 | 8,280.00 | 400.00 | -680.00 |  |  |  | **8,000.00** |
| Ben Okafor | 50,000.00 | 13,800.00 | 27.6% | 1 | 690.00 | 0.00 |  | -960.00 |  |  | **0.00** |
| Chloe Martin | 40,000.00 | 17,000.00 | 42.5% | 1 | 850.00 | 200.00 |  |  |  | +450.00 | **1,500.00** |
| **Total** | | | | | | | | | | | **9,500.00** |

## Ana Ruiz

| Deal | Account | Closed | Type | Amount | Share | Credit | Attainment after | Rate slices | Commission | SPIFF |
|---|---|---|---|---|---|---|---|---|---|---|
| D-1001 | Acme Logistics | 2026-10-03 | new | 18,000.00 | 100% | 18,000.00 | 30.0% | 18,000.00 x 5.0% | 900.00 | 200.00 |
| D-1002 | Brightwave Media | 2026-10-08 | expansion | 12,000.00 | 60% | 7,200.00 | 42.0% | 7,200.00 x 5.0% | 360.00 |  |
| D-1003 | Corvid Health | 2026-10-14 | new | 30,000.00 | 100% | 30,000.00 | 92.0% | 4,800.00 x 5.0%; 25,200.00 x 8.0% | 2,256.00 | 200.00 |
| D-1004 | Delta Foods | 2026-10-21 | renewal | 24,000.00 | 100% | 12,000.00 | 112.0% | 4,800.00 x 8.0%; 7,200.00 x 12.0% | 1,248.00 |  |
| D-1005 | Evergreen Bank | 2026-10-28 | new | 28,000.00 | 100% | 28,000.00 | 158.7% | 22,800.00 x 12.0%; 5,200.00 x 15.0% | 3,516.00 |  |

## Ben Okafor

| Deal | Account | Closed | Type | Amount | Share | Credit | Attainment after | Rate slices | Commission | SPIFF |
|---|---|---|---|---|---|---|---|---|---|---|
| D-1002 | Brightwave Media | 2026-10-08 | expansion | 12,000.00 | 40% | 4,800.00 | 9.6% | 4,800.00 x 5.0% | 240.00 |  |
| D-1008 | Harbor Clinics | 2026-10-24 | expansion | 9,000.00 | 100% | 9,000.00 | 27.6% | 9,000.00 x 5.0% | 450.00 |  |

- Clawback D-0950 (Ironclad Security, cancelled 55 days after close): -960.00
- Negative balance carried forward: -270.00

## Chloe Martin

| Deal | Account | Closed | Type | Amount | Share | Credit | Attainment after | Rate slices | Commission | SPIFF |
|---|---|---|---|---|---|---|---|---|---|---|
| D-1009 | Kestrel Labs | 2026-10-09 | new | 11,000.00 | 100% | 11,000.00 | 27.5% | 11,000.00 x 5.0% | 550.00 | 200.00 |
| D-1010 | Lumen Studio | 2026-10-30 | new | 6,000.00 | 100% | 6,000.00 | 42.5% | 6,000.00 x 5.0% | 300.00 |  |

- Draw: top-up 450.00, recovered 0.00, outstanding balance 1,050.00

## Held deals (need a decision)

| Deal | Account | Reason |
|---|---|---|
| D-1006 | Fjord Outdoor | discount 30% not approved |
| D-1007 | Granite Works | split 90% |
| D-1012 | Nimbus Air | unknown rep dave |

## Not counted this period

| Deal | Account | Reason |
|---|---|---|
| D-0871 | Juniper Retail | cancelled after clawback window |
| D-0990 | Meridian Travel | closed 2026-09-29 |
| D-1009 | Kestrel Labs | duplicate row |

## Findings

- **HIGH CM01** D-1012: unknown rep(s) dave in owner_split - deal held
- **HIGH CM02** D-1007: split shares add up to 90%, not 100% - deal held until sales ops confirms the split
- **MEDIUM CM04** D-1009 appears again on row 15 (first on row 12) - second row ignored
- **MEDIUM CM05** D-1006: discount 30% over the 20% limit without approval - credit held
- **MEDIUM CM08** D-0950 cancelled 55 days after close (window 90): clawback 960.00
- **MEDIUM CM11** Ben Okafor: clawbacks exceed earnings by 270.00 - payout 0, negative balance carried to next period (check your plan and local pay law before deducting)
- **LOW CM03** D-0990 closed 2026-09-29, outside October 2026 - not counted here (check it was paid in its own period)
- **LOW CM08** D-0871 cancelled 148 days after close (window 90) - no clawback
- **INFO CM06** Ana Ruiz: commission + SPIFFs 8,680.00 capped at max_payout 8,000.00 (-680.00)
- **INFO CM07** Ana Ruiz at 158.7% of quota - tier 4 (15.0%) reached for the credit above each threshold
- **INFO CM10** Chloe Martin: earned 1,050.00 is below the 1,500.00 draw - draw tops up 450.00 (recoverable: balance now 1,050.00)

Recompute check: commission per deal = sum of (credit in each tier band x tier rate); payout = commission + SPIFFs - cap - deferred - clawbacks + carry-in, then draw rules. Have sales ops and payroll approve before paying.
FILE:scripts/commission_calc.py
#!/usr/bin/env python3
"""Sales commission payout calculator (stdlib only, Python 3.8+).

Reads a compensation plan (JSON) and a deals export (CSV) and works out each rep's payout for
one period: quota credit by deal type, deal splits, tiered rates with accelerators (marginal or
flat/retroactive), credit and payout caps, SPIFFs, clawbacks for deals cancelled inside the
clawback window, payment on booking or on collection, and recoverable or non-recoverable draws.
Deals with problems (bad split, unknown rep, discount over the limit without approval,
duplicates, wrong period) are held or excluded and listed with a finding code.

Usage:
  commission_calc.py PLAN.json DEALS.csv [--rep ID]... [--json]
  commission_calc.py --self-test
Exit: 0 = no high findings, 1 = high findings (deals held that need a decision), 2 = bad input.
"""
import argparse, csv, datetime, io, json, sys
from collections import OrderedDict
from decimal import Decimal, ROUND_HALF_UP

SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
CENT = Decimal("0.01")
PLAN_KEYS = {"plan_name", "period", "currency", "tier_method", "tiers", "credit_cap_attainment", "max_payout",
             "deal_type_credit", "max_discount_pct", "unapproved_discount", "spiffs", "clawback", "pay_on", "reps"}


class InputError(Exception):
    pass


def D(x, what="number"):
    if isinstance(x, Decimal):
        return x
    s = str(x).strip().replace(" ", "")
    if s == "":
        raise InputError("empty %s" % what)
    if "," in s and "." in s:
        s = s.replace(",", "") if s.rfind(".") > s.rfind(",") else s.replace(".", "").replace(",", ".")
    elif "," in s:
        s = s.replace(",", "") if len(s.split(",")[-1]) == 3 else s.replace(",", ".")
    try:
        return Decimal(s)
    except Exception:
        raise InputError("%s is not a number: %r" % (what, x))


def pdate(s, what):
    try:
        return datetime.date.fromisoformat(str(s).strip())
    except ValueError:
        raise InputError("%s must be YYYY-MM-DD, got %r" % (what, s))


def money(x):
    return Decimal(x).quantize(CENT, rounding=ROUND_HALF_UP)


def fm(x):
    x = money(x)
    return "{:,.2f}".format(x)


def num(x):
    return "{:f}".format(Decimal(x).normalize())


def pct(x):
    return "%.1f%%" % (float(x) * 100)


# ---------------------------------------------------------------- plan
def load_plan(cfg):
    if not isinstance(cfg, dict):
        raise InputError("plan must be a JSON object")
    bad = set(cfg) - PLAN_KEYS
    if bad:
        raise InputError("unknown plan key(s): %s" % ", ".join(sorted(bad)))
    per = cfg.get("period") or {}
    p = {"name": cfg.get("plan_name", "Commission plan"), "currency": cfg.get("currency", ""),
         "start": pdate(per.get("start"), "period.start"), "end": pdate(per.get("end"), "period.end"),
         "label": per.get("label") or "%s..%s" % (per.get("start"), per.get("end")),
         "method": cfg.get("tier_method", "marginal"), "pay_on": cfg.get("pay_on", "booking"),
         "cap_att": D(cfg["credit_cap_attainment"]) if cfg.get("credit_cap_attainment") is not None else None,
         "max_payout": D(cfg["max_payout"]) if cfg.get("max_payout") is not None else None,
         "type_credit": {k.lower(): D(v) for k, v in (cfg.get("deal_type_credit") or {"new": 1}).items()},
         "max_disc": D(cfg["max_discount_pct"]) if cfg.get("max_discount_pct") is not None else None,
         "unapproved": cfg.get("unapproved_discount", "hold"),
         "spiffs": cfg.get("spiffs", []),
         "claw_days": int((cfg.get("clawback") or {}).get("window_days", 0)),
         "claw_share": D((cfg.get("clawback") or {}).get("share", 1)),
         "reps": OrderedDict()}
    if p["end"] < p["start"]:
        raise InputError("period ends before it starts")
    if p["method"] not in ("marginal", "flat"):
        raise InputError("tier_method must be 'marginal' or 'flat'")
    if p["pay_on"] not in ("booking", "collection"):
        raise InputError("pay_on must be 'booking' or 'collection'")
    if p["unapproved"] not in ("hold", "pay"):
        raise InputError("unapproved_discount must be 'hold' or 'pay'")
    tiers, prev = [], Decimal(0)
    for i, t in enumerate(cfg.get("tiers") or []):
        up = D(t["up_to"], "tier up_to") if t.get("up_to") is not None else None
        if up is not None and up <= prev:
            raise InputError("tier %d: up_to must increase (attainment as a fraction, e.g. 1.0 = 100%%)" % (i + 1))
        if up is None and i != len(cfg["tiers"]) - 1:
            raise InputError("only the last tier may omit up_to")
        tiers.append((prev, up, D(t["rate"], "tier rate")))
        prev = up if up is not None else prev
    if not tiers:
        raise InputError("plan needs at least one tier")
    if any(r > 1 for _, _, r in tiers):
        raise InputError("tier rates are fractions (0.08 = 8%)")
    p["tiers"] = tiers
    for rid, r in (cfg.get("reps") or {}).items():
        dr = r.get("draw")
        p["reps"][rid.lower()] = {"id": rid.lower(), "name": r.get("name", rid), "quota": D(r["quota"], "quota of %s" % rid),
                                  "draw": D(dr["amount"]) if dr else Decimal(0), "recoverable": bool(dr and dr.get("recoverable", True)),
                                  "balance": D(dr.get("balance", 0)) if dr else Decimal(0),
                                  "carry": D(r.get("carry_forward", 0))}
        if p["reps"][rid.lower()]["quota"] <= 0:
            raise InputError("quota of %s must be positive" % rid)
    if not p["reps"]:
        raise InputError("plan needs at least one rep")
    return p


def rate_at(p, att):
    for lo, up, r in p["tiers"]:
        if up is None or att < up:
            return r
    return p["tiers"][-1][2]


def tier_name(p, att):
    for i, (lo, up, r) in enumerate(p["tiers"], 1):
        if up is None or att < up:
            return i
    return len(p["tiers"])


# ---------------------------------------------------------------- deals
def parse_split(s, default):
    s = (s or "").strip()
    if not s:
        return OrderedDict([(default, Decimal(100))]) if default else None
    out = OrderedDict()
    for part in s.replace("|", ";").split(";"):
        part = part.strip()
        if not part:
            continue
        if ":" in part:
            who, share = part.split(":", 1)
            out[who.strip().lower()] = out.get(who.strip().lower(), Decimal(0)) + D(share.strip().rstrip("%"), "split share")
        else:
            out[part.lower()] = Decimal(100)
    return out


def yes(v):
    return str(v or "").strip().lower() in ("yes", "y", "true", "1", "x")


def compute(p, deals_text, only=None):
    F = []
    rows = list(csv.DictReader(io.StringIO(deals_text)))
    if not rows:
        raise InputError("deals CSV has no rows")
    need = {"deal_id", "close_date", "amount"}
    if not need <= set(rows[0]):
        raise InputError("deals CSV needs columns %s (has %s)" % (", ".join(sorted(need)), ", ".join(rows[0])))
    credited = {rid: [] for rid in p["reps"]}
    held, excluded, claws, seen = [], [], [], {}
    for n, r in enumerate(rows, 2):
        did = (r.get("deal_id") or "").strip() or "row %d" % n
        acct = r.get("account", "")
        try:
            close = pdate(r.get("close_date"), "close_date")
            amount = D(r.get("amount"), "amount")
            disc = D(r.get("discount_pct") or 0, "discount_pct")
        except InputError as e:
            F.append(("high", "CM01", "%s (row %d): %s - deal not counted" % (did, n, e), did)); held.append((did, acct, "bad data")); continue
        key = did.lower()
        if key in seen:
            F.append(("medium", "CM04", "%s appears again on row %d (first on row %d) - second row ignored" % (did, n, seen[key]), did)); excluded.append((did, acct, "duplicate row")); continue
        seen[key] = n
        status = (r.get("status") or "won").strip().lower()
        split = parse_split(r.get("owner_split") or r.get("rep"), None)
        if not split:
            F.append(("high", "CM01", "%s: no rep / owner_split - deal not counted" % did, did)); held.append((did, acct, "no rep")); continue
        unknown = [w for w in split if w not in p["reps"]]
        if status == "cancelled":
            cdate = r.get("cancel_date")
            try:
                cdate = pdate(cdate, "cancel_date") if cdate else None
            except InputError as e:
                F.append(("high", "CM01", "%s: %s" % (did, e), did)); continue
            if not cdate or not (p["start"] <= cdate <= p["end"]):
                excluded.append((did, acct, "cancelled outside this period")); continue
            if p["start"] <= close <= p["end"]:
                F.append(("info", "CM08", "%s closed and was cancelled in this period - not credited" % did, did)); excluded.append((did, acct, "cancelled in period")); continue
            age = (cdate - close).days
            if age > p["claw_days"]:
                F.append(("low", "CM08", "%s cancelled %d days after close (window %d) - no clawback" % (did, age, p["claw_days"]), did)); excluded.append((did, acct, "cancelled after clawback window")); continue
            paid = r.get("commission_paid")
            if paid and str(paid).strip():
                base = D(paid, "commission_paid"); est = False
            else:
                tc = p["type_credit"].get((r.get("type") or "new").strip().lower(), Decimal(1))
                base = amount * tc * p["tiers"][0][2]; est = True
            for who, share in split.items():
                if who in p["reps"]:
                    amt = money(base * p["claw_share"] * share / 100)
                    claws.append({"rep": who, "deal": did, "account": acct, "amount": amt, "age": age, "estimated": est})
            F.append(("medium", "CM08", "%s cancelled %d days after close (window %d): clawback %s%s" % (did, age, p["claw_days"], fm(base * p["claw_share"]), " (estimated at the first tier rate - commission_paid missing)" if est else ""), did))
            continue
        if status not in ("won", "closed_won", "closed won"):
            excluded.append((did, acct, "status %s" % status)); continue
        if not (p["start"] <= close <= p["end"]):
            F.append(("low", "CM03", "%s closed %s, outside %s - not counted here (check it was paid in its own period)" % (did, close, p["label"]), did)); excluded.append((did, acct, "closed %s" % close)); continue
        if unknown:
            F.append(("high", "CM01", "%s: unknown rep(s) %s in owner_split - deal held" % (did, ", ".join(unknown)), did)); held.append((did, acct, "unknown rep %s" % ", ".join(unknown))); continue
        total = sum(split.values())
        if total != 100:
            F.append(("high", "CM02", "%s: split shares add up to %s%%, not 100%% - deal held until sales ops confirms the split" % (did, num(total)), did)); held.append((did, acct, "split %s%%" % num(total))); continue
        dtype = (r.get("type") or "new").strip().lower()
        if dtype not in p["type_credit"]:
            F.append(("high", "CM01", "%s: deal type '%s' has no credit rate in the plan - deal held" % (did, dtype), did)); held.append((did, acct, "unknown type %s" % dtype)); continue
        if amount <= 0:
            F.append(("medium", "CM01", "%s: amount %s is not positive - not counted (use a cancellation for reversals)" % (did, amount), did)); excluded.append((did, acct, "amount <= 0")); continue
        if p["max_disc"] is not None and disc > p["max_disc"] and not yes(r.get("approved")):
            if p["unapproved"] == "hold":
                F.append(("medium", "CM05", "%s: discount %s%% over the %s%% limit without approval - credit held" % (did, num(disc), num(p["max_disc"])), did)); held.append((did, acct, "discount %s%% not approved" % num(disc))); continue
            F.append(("medium", "CM05", "%s: discount %s%% over the %s%% limit without approval - paid anyway (unapproved_discount=pay)" % (did, num(disc), num(p["max_disc"])), did))
        credit_total = amount * p["type_credit"][dtype]
        deferred = p["pay_on"] == "collection" and not yes(r.get("collected"))
        for who, share in split.items():
            spiff = Decimal(0)
            for s in p["spiffs"]:
                if all(str(r.get(k, "")).strip().lower() == str(v).lower() for k, v in (s.get("when") or {}).items()):
                    spiff += D(s["amount"]) * share / 100
            credited[who].append({"deal": did, "account": acct, "close": close, "type": dtype, "amount": amount, "share": share,
                                  "credit": credit_total * share / 100, "spiff": spiff, "deferred": deferred})
    # per rep
    reps = []
    for rid, rep in p["reps"].items():
        if only and rid not in only:
            continue
        ds = sorted(credited[rid], key=lambda d: (d["close"], d["deal"]))
        q = rep["quota"]
        cap_credit = q * p["cap_att"] if p["cap_att"] is not None else None
        cum = Decimal(0)
        total_credit = sum((d["credit"] for d in ds), Decimal(0))
        att = total_credit / q
        flat_rate = rate_at(p, min(att, p["cap_att"]) if p["cap_att"] is not None else att)
        for d in ds:
            a, b = cum, cum + d["credit"]
            cum = b
            comm, slices = Decimal(0), []
            if p["method"] == "flat":
                eligible = b if cap_credit is None else min(b, cap_credit)
                part = max(Decimal(0), eligible - a)
                comm = part * flat_rate
                slices.append("%s x %s" % (fm(part), pct(flat_rate)))
                if part < d["credit"]:
                    slices.append("%s over the credit cap x 0%%" % fm(d["credit"] - part))
            else:
                for lo, up, rate in p["tiers"]:
                    lo_c, up_c = lo * q, (up * q if up is not None else None)
                    if cap_credit is not None:
                        up_c = cap_credit if up_c is None else min(up_c, cap_credit)
                        if lo_c >= cap_credit:
                            continue
                    x0, x1 = max(a, lo_c), (b if up_c is None else min(b, up_c))
                    if x1 > x0:
                        comm += (x1 - x0) * rate
                        slices.append("%s x %s" % (fm(x1 - x0), pct(rate)))
                if cap_credit is not None and b > cap_credit:
                    slices.append("%s over the credit cap x 0%%" % fm(b - max(a, cap_credit)))
            d["commission"] = money(comm)
            d["slices"] = slices
            d["att_after"] = b / q
        commission = sum((d["commission"] for d in ds), Decimal(0))
        spiffs = money(sum((d["spiff"] for d in ds), Decimal(0)))
        earned = commission + spiffs
        capped = Decimal(0)
        if p["max_payout"] is not None and earned > p["max_payout"]:
            capped = earned - p["max_payout"]
            F.append(("info", "CM06", "%s: commission + SPIFFs %s capped at max_payout %s (-%s)" % (rep["name"], fm(earned), fm(p["max_payout"]), fm(capped)), rid))
            earned = p["max_payout"]
        if cap_credit is not None and total_credit > cap_credit:
            F.append(("info", "CM06", "%s: credit above %s of quota (%s) earns no commission" % (rep["name"], pct(p["cap_att"]), fm(total_credit - cap_credit)), rid))
        if att >= 1:
            F.append(("info", "CM07", "%s at %s of quota - tier %d (%s) reached%s" % (rep["name"], pct(att), tier_name(p, att), pct(rate_at(p, att)), " and applied to all credit (flat method)" if p["method"] == "flat" else " for the credit above each threshold"), rid))
        deferred = money(sum((d["commission"] + d["spiff"] for d in ds if d["deferred"]), Decimal(0)))
        if deferred and capped:
            deferred = money(deferred * earned / (earned + capped))
        if deferred:
            F.append(("low", "CM09", "%s: %s deferred until the customer pays (pay_on collection)" % (rep["name"], fm(deferred)), rid))
        claw = sum((c["amount"] for c in claws if c["rep"] == rid), Decimal(0))
        payable = earned - deferred - claw + rep["carry"]
        draw_paid, recovered, bal = Decimal(0), Decimal(0), rep["balance"]
        if rep["draw"]:
            if payable < rep["draw"]:
                draw_paid = rep["draw"] - max(payable, Decimal(0))
                if rep["recoverable"]:
                    bal += draw_paid
                F.append(("info", "CM10", "%s: earned %s is below the %s draw - draw tops up %s%s" % (rep["name"], fm(max(payable, Decimal(0))), fm(rep["draw"]), fm(draw_paid), (" (recoverable: balance now %s)" % fm(bal)) if rep["recoverable"] else " (non-recoverable)"), rid))
                payout = rep["draw"]
                if payable < 0:
                    bal += -payable if rep["recoverable"] else 0
            else:
                recovered = min(bal, payable - rep["draw"]) if rep["recoverable"] else Decimal(0)
                bal -= recovered
                payout = payable - recovered
                if recovered:
                    F.append(("info", "CM10", "%s: %s of the outstanding draw recovered (balance now %s)" % (rep["name"], fm(recovered), fm(bal)), rid))
        else:
            payout = payable
        carry_out = Decimal(0)
        if payout < 0:
            carry_out = payout
            F.append(("medium", "CM11", "%s: clawbacks exceed earnings by %s - payout 0, negative balance carried to next period (check your plan and local pay law before deducting)" % (rep["name"], fm(-payout)), rid))
            payout = Decimal(0)
        reps.append({"id": rid, "name": rep["name"], "quota": q, "credit": total_credit, "attainment": att,
                     "tier": tier_name(p, att), "commission": commission, "spiffs": spiffs, "capped": capped,
                     "clawbacks": claw, "deferred": deferred, "carry_in": rep["carry"], "draw_topup": draw_paid,
                     "draw_recovered": recovered, "draw_balance": bal, "carry_out": carry_out, "payout": money(payout), "deals": ds})
    F.sort(key=lambda f: (SEV[f[0]], f[1], str(f[3])))
    return {"plan": p, "reps": reps, "held": held, "excluded": excluded, "clawbacks": claws, "findings": F}


# ---------------------------------------------------------------- report
def report(res, title):
    p, F = res["plan"], res["findings"]
    c = {s: sum(1 for f in F if f[0] == s) for s in SEV}
    cur = (" " + p["currency"]) if p["currency"] else ""
    total = sum((r["payout"] for r in res["reps"]), Decimal(0))
    L = ["# Commission payouts: %s, %s" % (p["name"], p["label"]), ""]
    L.append("Period %s to %s, %s tiers (%s), pay on %s%s%s. Source: %s." % (
        p["start"], p["end"], p["method"], ", ".join("%s%s" % (pct(r), (" up to %s of quota" % pct(u)) if u is not None else " above") for _, u, r in p["tiers"]),
        p["pay_on"], (", credit cap %s of quota" % pct(p["cap_att"])) if p["cap_att"] is not None else "",
        (", max payout %s" % fm(p["max_payout"])) if p["max_payout"] is not None else "", title))
    L.append("")
    if c["high"]:
        L.append("**Verdict: %d high finding(s), %d deal(s) held - resolve them before payroll; total payable now %s%s**" % (c["high"], len(res["held"]), fm(total), cur))
    else:
        L.append("**Verdict: ready for approval - total payable %s%s%s**" % (fm(total), cur, " (medium findings to review)" if c["medium"] else ""))
    L.append("")
    L.append("Findings: %d high, %d medium, %d low, %d info. Figures follow the plan file you provided; the signed comp plan and local pay law win if they differ." % (c["high"], c["medium"], c["low"], c["info"]))
    L += ["", "## Payouts", "", "| Rep | Quota | Credit | Attainment | Tier | Commission | SPIFFs | Cap | Clawbacks | Deferred | Draw | Payout |", "|---|---|---|---|---|---|---|---|---|---|---|---|"]
    for r in res["reps"]:
        draw = ("+%s" % fm(r["draw_topup"])) if r["draw_topup"] else (("-%s recovered" % fm(r["draw_recovered"])) if r["draw_recovered"] else "")
        L.append("| %s | %s | %s | %s | %d | %s | %s | %s | %s | %s | %s | **%s** |" % (
            r["name"], fm(r["quota"]), fm(r["credit"]), pct(r["attainment"]), r["tier"], fm(r["commission"]), fm(r["spiffs"]),
            ("-" + fm(r["capped"])) if r["capped"] else "", ("-" + fm(r["clawbacks"])) if r["clawbacks"] else "",
            fm(r["deferred"]) if r["deferred"] else "", draw, fm(r["payout"])))
    L.append("| **Total** | | | | | | | | | | | **%s** |" % fm(total))
    for r in res["reps"]:
        L += ["", "## %s" % r["name"], ""]
        if not r["deals"]:
            L.append("No credited deals this period.")
        else:
            L += ["| Deal | Account | Closed | Type | Amount | Share | Credit | Attainment after | Rate slices | Commission | SPIFF |", "|---|---|---|---|---|---|---|---|---|---|---|"]
            for d in r["deals"]:
                L.append("| %s | %s | %s | %s | %s | %s%% | %s | %s | %s | %s | %s |" % (
                    d["deal"], d["account"], d["close"], d["type"], fm(d["amount"]), num(d["share"]), fm(d["credit"]),
                    pct(d["att_after"]), "; ".join(d["slices"]) or "-", fm(d["commission"]) + (" (deferred)" if d["deferred"] else ""), fm(d["spiff"]) if d["spiff"] else ""))
        extra = []
        for cl in [x for x in res["clawbacks"] if x["rep"] == r["id"]]:
            extra.append("- Clawback %s (%s, cancelled %d days after close): -%s%s" % (cl["deal"], cl["account"], cl["age"], fm(cl["amount"]), " (estimated)" if cl["estimated"] else ""))
        if r["carry_in"]:
            extra.append("- Carried in from last period: %s" % fm(r["carry_in"]))
        if r["draw_topup"] or r["draw_recovered"] or r["draw_balance"]:
            extra.append("- Draw: top-up %s, recovered %s, outstanding balance %s" % (fm(r["draw_topup"]), fm(r["draw_recovered"]), fm(r["draw_balance"])))
        if r["carry_out"]:
            extra.append("- Negative balance carried forward: %s" % fm(r["carry_out"]))
        L += [""] + extra if extra else []
    if res["held"]:
        L += ["", "## Held deals (need a decision)", "", "| Deal | Account | Reason |", "|---|---|---|"]
        L += ["| %s | %s | %s |" % h for h in res["held"]]
    if res["excluded"]:
        L += ["", "## Not counted this period", "", "| Deal | Account | Reason |", "|---|---|---|"]
        L += ["| %s | %s | %s |" % h for h in res["excluded"]]
    L += ["", "## Findings", ""]
    L += ["- **%s %s** %s" % (s.upper(), code, m) for s, code, m, _ in F] or ["- none"]
    L += ["", "Recompute check: commission per deal = sum of (credit in each tier band x tier rate); payout = commission + SPIFFs - cap - deferred - clawbacks + carry-in, then draw rules. Have sales ops and payroll approve before paying."]
    return "\n".join(L) + "\n"


def to_json(res):
    def j(x):
        if isinstance(x, Decimal):
            return float(money(x))
        if isinstance(x, datetime.date):
            return x.isoformat()
        if isinstance(x, dict):
            return {k: j(v) for k, v in x.items()}
        if isinstance(x, (list, tuple)):
            return [j(v) for v in x]
        return x
    out = {"period": res["plan"]["label"], "reps": [], "held": [list(h) for h in res["held"]], "excluded": [list(h) for h in res["excluded"]],
           "clawbacks": j(res["clawbacks"]), "findings": [{"severity": s, "code": c, "message": m} for s, c, m, _ in res["findings"]]}
    for r in res["reps"]:
        rr = {k: v for k, v in r.items() if k != "deals"}
        rr["attainment"] = round(float(r["attainment"]), 4)
        rr = j(rr)
        rr["deals"] = [j({k: v for k, v in d.items() if k not in ("att_after",)}) for d in r["deals"]]
        out["reps"].append(rr)
    return out


# ---------------------------------------------------------------- self-test
def self_test():
    tests = []
    def t(name, cond):
        tests.append((name, bool(cond)))
    base = {"period": {"start": "2026-10-01", "end": "2026-10-31"}, "tiers": [{"up_to": 0.5, "rate": 0.05}, {"up_to": 1.0, "rate": 0.08}, {"rate": 0.12}],
            "reps": {"a": {"quota": 10000}, "b": {"quota": 10000}}}
    H = "deal_id,close_date,owner_split,type,amount,discount_pct,billing,status,cancel_date,commission_paid,approved,collected\n"
    def run(rows, **over):
        cfg = dict(base); cfg.update(over)
        return compute(load_plan(cfg), H + rows)
    def rep(res, rid):
        return next(r for r in res["reps"] if r["id"] == rid)
    def codes(res):
        return [(f[0], f[1]) for f in res["findings"]]
    r = run("1,2026-10-05,a,new,4000,,,won,,,,\n")
    t("below first threshold: 4000 x 5% = 200", rep(r, "a")["commission"] == Decimal("200.00"))
    r = run("1,2026-10-05,a,new,12000,,,won,,,,\n")
    t("marginal: 5000x5% + 5000x8% + 2000x12% = 890", rep(r, "a")["commission"] == Decimal("890.00"))
    r = run("1,2026-10-05,a,new,12000,,,won,,,,\n", tier_method="flat")
    t("flat: 12000 x 12% = 1440", rep(r, "a")["commission"] == Decimal("1440.00"))
    r = run("1,2026-10-05,a,new,6000,,,won,,,,\n2,2026-10-20,a,new,6000,,,won,,,,\n")
    t("tiers follow close order across deals", [d["commission"] for d in rep(r, "a")["deals"]] == [Decimal("330.00"), Decimal("560.00")])
    t("accelerator reached -> CM07", ("info", "CM07") in codes(r))
    r = run("1,2026-10-05,a,new,30000,,,won,,,,\n", credit_cap_attainment=2.0)
    t("credit cap at 200%: 250+400+1200 = 1850", rep(r, "a")["commission"] == Decimal("1850.00") and ("info", "CM06") in codes(r))
    r = run("1,2026-10-05,a,new,30000,,,won,,,,\n", max_payout=1000)
    t("max_payout caps the payout", rep(r, "a")["payout"] == Decimal("1000.00"))
    r = run("1,2026-10-05,a,renewal,10000,,,won,,,,\n", deal_type_credit={"new": 1, "renewal": 0.5})
    t("renewal credited at 50%", rep(r, "a")["credit"] == Decimal("5000") and rep(r, "a")["commission"] == Decimal("250.00"))
    r = run("1,2026-10-05,a:60;b:40,new,10000,,,won,,,,\n")
    t("split 60/40 credits both reps", rep(r, "a")["credit"] == Decimal("6000") and rep(r, "b")["credit"] == Decimal("4000"))
    r = run("1,2026-10-05,a:60;b:30,new,10000,,,won,,,,\n")
    t("split not 100% -> CM02 high, held", ("high", "CM02") in codes(r) and rep(r, "a")["credit"] == 0 and r["held"])
    r = run("1,2026-10-05,zed,new,10000,,,won,,,,\n")
    t("unknown rep -> CM01 high", ("high", "CM01") in codes(r))
    r = run("1,2026-10-05,a,new,10000,30,,won,,,,\n", max_discount_pct=20)
    t("discount over limit unapproved -> CM05 held", ("medium", "CM05") in codes(r) and rep(r, "a")["credit"] == 0)
    r = run("1,2026-10-05,a,new,10000,30,,won,,,yes,\n", max_discount_pct=20)
    t("approved discount is paid", rep(r, "a")["credit"] == Decimal("10000"))
    r = run("1,2026-09-28,a,new,10000,,,won,,,,\n")
    t("closed outside period -> CM03, not counted", ("low", "CM03") in codes(r) and rep(r, "a")["credit"] == 0)
    r = run("1,2026-10-05,a,new,4000,,,won,,,,\n1,2026-10-05,a,new,4000,,,won,,,,\n")
    t("duplicate deal id -> CM04, counted once", ("medium", "CM04") in codes(r) and rep(r, "a")["credit"] == Decimal("4000"))
    r = run("1,2026-10-05,a,new,4000,,,won,,,,\n9,2026-08-20,a,new,8000,,,cancelled,2026-10-10,400,,\n", clawback={"window_days": 90})
    t("cancelled within window -> clawback of paid commission", rep(r, "a")["clawbacks"] == Decimal("400.00") and rep(r, "a")["payout"] == 0 and rep(r, "a")["carry_out"] == Decimal("-200.00") and ("medium", "CM11") in codes(r))
    r = run("9,2026-05-01,a,new,8000,,,cancelled,2026-10-10,400,,\n", clawback={"window_days": 90})
    t("cancelled after window -> no clawback", rep(r, "a")["clawbacks"] == 0 and ("low", "CM08") in codes(r))
    r = run("9,2026-09-20,a,new,8000,,,cancelled,2026-10-10,,,\n", clawback={"window_days": 90})
    t("missing commission_paid -> estimated at first tier", rep(r, "a")["clawbacks"] == Decimal("400.00"))
    r = run("1,2026-10-05,a,new,4000,,annual,won,,,,\n", spiffs=[{"name": "Annual", "when": {"billing": "annual"}, "amount": 150}])
    t("SPIFF added", rep(r, "a")["spiffs"] == Decimal("150.00") and rep(r, "a")["payout"] == Decimal("350.00"))
    r = run("1,2026-10-05,a,new,4000,,,won,,,,no\n", pay_on="collection")
    t("pay on collection defers unpaid deals", rep(r, "a")["deferred"] == Decimal("200.00") and rep(r, "a")["payout"] == 0)
    cfg = dict(base, reps={"a": {"quota": 10000, "draw": {"amount": 500, "recoverable": True, "balance": 0}}})
    res = compute(load_plan(cfg), H + "1,2026-10-05,a,new,4000,,,won,,,,\n")
    t("recoverable draw tops up and builds balance", rep(res, "a")["payout"] == Decimal("500.00") and rep(res, "a")["draw_balance"] == Decimal("300"))
    cfg = dict(base, reps={"a": {"quota": 10000, "draw": {"amount": 500, "recoverable": True, "balance": 300}}})
    res = compute(load_plan(cfg), H + "1,2026-10-05,a,new,12000,,,won,,,,\n")
    t("surplus recovers draw balance", rep(res, "a")["draw_recovered"] == Decimal("300") and rep(res, "a")["payout"] == Decimal("590.00"))
    cfg = dict(base, reps={"a": {"quota": 10000, "draw": {"amount": 500, "recoverable": False}}})
    res = compute(load_plan(cfg), H + "1,2026-10-05,a,new,4000,,,won,,,,\n")
    t("non-recoverable draw: no balance", rep(res, "a")["payout"] == Decimal("500.00") and rep(res, "a")["draw_balance"] == 0)
    t("thousands separator parsed", D("12,000") == Decimal("12000") and D("1.234,50") == Decimal("1234.50"))
    for label, bad in [("tiers out of order", dict(base, tiers=[{"up_to": 1, "rate": 0.05}, {"up_to": 0.5, "rate": 0.08}])),
                       ("rate as percent", dict(base, tiers=[{"rate": 8}])), ("unknown key", dict(base, quotas=1))]:
        try:
            load_plan(bad); ok = False
        except InputError:
            ok = True
        t("bad plan rejected: " + label, ok)
    rr = run("1,2026-10-05,a,new,4000,,,won,,,,\n")
    t("report has verdict and payouts table", "**Verdict:" in report(rr, "t") and "## Payouts" in report(rr, "t"))
    for name, ok in tests:
        print(("PASS  " if ok else "FAIL  ") + name)
    passed = sum(ok for _, ok in tests)
    print("%d/%d passed" % (passed, len(tests)))
    return 0 if passed == len(tests) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Calculate sales commission payouts for one period.")
    ap.add_argument("plan", nargs="?")
    ap.add_argument("deals", nargs="?")
    ap.add_argument("--rep", action="append", default=[], help="only show this rep id (repeatable)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.plan or not a.deals:
        ap.print_usage(sys.stderr)
        return 2
    try:
        try:
            cfg = json.load(open(a.plan, encoding="utf-8"))
        except json.JSONDecodeError as e:
            raise InputError("plan is not valid JSON: %s" % e)
        p = load_plan(cfg)
        res = compute(p, open(a.deals, encoding="utf-8-sig").read(), [x.lower() for x in a.rep] or None)
    except (OSError, InputError, KeyError) as e:
        print("error: %s" % (("missing field %s" % e) if isinstance(e, KeyError) else e), file=sys.stderr)
        return 2
    if a.json:
        print(json.dumps(to_json(res), indent=2))
    else:
        sys.stdout.write(report(res, a.deals))
    return 1 if any(f[0] == "high" for f in res["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonSalesFinance+2
F@f
0
车辆保养服务日程规划器
技能

按车型、里程与气候生成保养日程与下次服务提醒。

---
name: car-maintenance-service-schedule-planner
description: 按车型、里程与气候生成保养日程与下次服务提醒。
---

# 车辆保养服务日程规划器

你是「车辆保养服务日程规划器」技能助手。上方 description 为中文任务说明。下方保留原技能完整正文(含脚本与代码);执行时遵循中文说明与原文技术细节。

# Car Maintenance Service Schedule Planner

You help car owners keep up with maintenance without overpaying. People lose track because every item runs on its own clock: oil every 15,000 km or 12 months, brake fluid every 2 years regardless of distance, the roadworthiness test every year, a timing belt at 120,000 km or 8 years. Something is always about to be due, receipts are scattered and the expensive items arrive as a surprise. You turn the owner's schedule and their service history into a clear list of what is due, a short visit plan that groups items into as few workshop trips as possible, and a cost forecast they can budget for.

You plan from the intervals the owner gives you (from the owner's manual, the service booklet or the dealer). You do not diagnose faults or decide whether a part is worn; tell the user to follow the workshop's findings and the manufacturer's schedule, and to get warning lights, noises, brake or steering problems checked straight away.

## Files in this skill

- `scripts/car_service_plan.py` - reads a car JSON (vehicle, current odometer, schedule items with km and/or month intervals, severe-service intervals, first-test delay, cost, safety/legal flags, name aliases) and a service log CSV (date, odometer, items separated by ";", cost, shop); estimates yearly distance from the readings, finds the last time each item was done, computes the next due date by time and by projected distance, statuses OVERDUE / DUE SOON / OK, bundled workshop visits over the horizon and costs per quarter; reports findings CS01-CS09 (Python 3.8+ standard library only)
- `references/intervals-and-severe-service.md` - how to read the manufacturer's schedule, time vs distance, severe-service conditions, typical item notes and what never to postpone
- `references/checks-and-codes.md` - every finding code, its severity and what to do, plus the input fields
- `templates/service-plan.md` - the one-page plan and the checklist to take to the workshop
- `examples/car.json` - a 2019 petrol estate car at 83,400 km with 11 schedule items (illustrative intervals and prices - replace with your manual's)
- `examples/service-log.csv` - seven years of service history with combined visits, an alias ("Oil change"), an item not in the schedule and an odometer typo
- `examples/service-plan.md` - the real planner output on the example

## Workflow

### 1. Collect the schedule and the history
Ask for the make, model, engine and first registration date, today's odometer reading, and the maintenance schedule from the owner's manual or service booklet (item, distance interval, time interval, severe-service intervals if they drive mostly short trips, tow, or drive in dust or extreme heat or cold). Ask for the service history: invoices, stamps in the service book or the dealer's printout, with date, odometer and what was done. Add rough prices from their workshop's last invoices. Write them into a car JSON like `examples/car.json` and a log like `examples/service-log.csv`.

### 2. Run the planner
```
python3 scripts/car_service_plan.py examples/car.json examples/service-log.csv
python3 scripts/car_service_plan.py car.json log.csv --odometer 84100 --today 2026-11-20
python3 scripts/car_service_plan.py car.json log.csv --severe --horizon 36
python3 scripts/car_service_plan.py car.json log.csv --json
python3 scripts/car_service_plan.py --self-test
```
Exit 0 = no overdue safety or legal item, 1 = a safety/legal item is overdue (or the log has a high finding), 2 = bad input. Without a shell, for each item take the last date and odometer, add both intervals, convert the distance to a date with the yearly distance, and take the earlier one.

### 3. Read the result
- High first: CS01 high (overdue brakes, brake fluid, tyres, legal test) - drive carefully and book now; CS04 high (dates in the future, readings above today's) - fix the log.
- Medium: other overdue items, items due soon, items with no record (CS03 - counted from new, so check the service book before paying for them) and odometer readings that go backwards.
- Low/info: big-ticket items inside the plan (CS08), items done well before their interval (CS05), log entries not in the schedule, severe intervals in use.
- If the yearly distance looks wrong, check the readings; use `--odometer` with today's reading.

### 4. Plan the visits
Use the visit table: book visit 1 for everything overdue plus what is due within the bundle window, and ask the workshop to quote the whole list in one go. Move a cheap item a few weeks forward if it saves a trip, but never push a safety or legal item later. Fill `templates/service-plan.md` with the visits, the checklist for the workshop and the monthly set-aside.

### 5. Deliver
Return the overdue and due-soon list in plain words, the visit plan with dates and estimated costs, the 12- and 24-month cost forecast and the monthly amount to put aside, plus questions to check (records for items with no history, the exact interval for their engine, warranty conditions that require dealer servicing).

## Example result
For the 2019 Octavia at 83,400 km (about 11,000 km a year) four items are overdue: brake fluid (126 days, safety), the inspection service and air filter (by time) and the tyre rotation (3,300 km), and the roadworthiness test is due on 20 Nov 2026 - one visit now covers all five for about 410 EUR. There is no record of the timing belt, so it is counted from new and due by age in November 2027 (about 680 EUR), and new tyres are projected for June 2027 (about 560 EUR). Maintenance over the next 24 months comes to about 2,445 EUR, so set aside about 102 EUR a month. The 4,790 km reading in Sept 2023 is a typo and is ignored.

## Rules

- Use the manufacturer's intervals for the exact engine; the example values are illustrative only.
- Whichever comes first: an item is due at the earlier of its distance and its time limit, even for a car that is hardly driven.
- Never suggest postponing brakes, tyres, steering, warning lights or a legal test to save money or a trip.
- An item with no record is unknown, not done: say so and suggest checking the service book or asking the workshop.
- Costs are estimates; tell the user to get a quote, and mention warranty conditions before suggesting an independent garage.
FILE:references/intervals-and-severe-service.md
# Intervals and severe service

## Where the intervals come from
- The owner's manual or service booklet for your exact model, engine and model year. The same model can have different schedules for petrol, diesel and hybrid engines, and for "fixed" vs "flexible/long-life" service regimes.
- Many cars show the next oil service on the dashboard (service indicator). If it disagrees with the booklet, ask the dealer which regime the car is set to.
- Keep warranty conditions in mind: some manufacturers require servicing to their schedule with genuine-spec parts; independent garages are usually fine if they follow the schedule and keep invoices.

## Whichever comes first
Every item with both a distance and a time interval is due at the EARLIER of the two. Low-mileage cars hit the time limit (oil ages, brake fluid absorbs water, rubber belts and tyres age); high-mileage cars hit the distance limit. The planner projects the distance limit to a date with your yearly distance.

## Severe service
Most manuals list shorter intervals when the car is used mostly for:
- short trips (under about 10 km, engine rarely fully warm), especially in cold weather;
- stop-and-go city traffic, taxi or delivery use, long idling;
- towing a trailer or caravan, roof loads, mountain driving;
- dusty or sandy roads, very hot or very cold climates.
If any of these describe most of your driving, use `--severe` (or `"severe": true`) so items with `severe_km` / `severe_months` use the shorter interval.

## Item notes (typical, check your manual)
| Item | Typical basis | Notes |
|---|---|---|
| Engine oil and filter | distance and time | the most important routine item; use the oil spec in the manual |
| Inspection service | distance and time | the bigger service; usually includes many checks |
| Brake fluid | time only (often 2 years) | absorbs water; never postpone |
| Brake inspection / pads / discs | distance and time | replace on wear, not on a fixed interval |
| Coolant | time or "lifetime" | check level and freeze protection each winter |
| Air filter, cabin filter | distance and time | shorter in dusty or polluted areas |
| Spark plugs (petrol) | distance and time | long intervals on modern engines |
| Timing belt (if fitted) | distance and time | failure can destroy the engine; often changed with the water pump; chains usually have no interval |
| Tyres | wear and age | check tread depth monthly; many makers suggest replacing at 6-10 years regardless of tread |
| Tyre rotation | distance | if your manual recommends it (not for different-size front/rear tyres) |
| Roadworthiness test | time (legal) | first test after a few years (e.g. 3 or 4), then every 1-2 years depending on the country |
| 12 V battery | condition | test before winter once it is 4-5 years old |

## Legal tests
Rules differ by country (MOT in the UK, TUV/HU in Germany, CT in France, TUVTURK in Turkey, state inspections in the US). Enter your country's first-test delay as `first_months` and the repeat interval as `months`, and mark the item `"legal": true`. Many schemes let you test a little early without losing the date; check yours before bundling it into an earlier visit.

## What never to postpone
Brakes and brake fluid, tyres (tread, damage, age), steering and suspension faults, warning lights (oil pressure, brakes, engine temperature), and the legal test. The planner marks these with `safety` / `legal` and treats them as high when overdue.

## Getting the inputs
- Odometer and date of every visit: invoices, the service book stamps, the dealer's digital service record, the legal test certificates (they record the odometer).
- Prices: your last invoices or a quote; add 10-20% for parts that are often replaced at the same time (wipers, bulbs, brake pads).
FILE:references/checks-and-codes.md
# Checks and finding codes

| Code | Severity | Check | What to do |
|---|---|---|---|
| CS01 | high: safety or legal item overdue; medium: other item overdue | due date or due distance passed | book it; for safety items drive carefully until done |
| CS02 | medium | due within `due_soon.days` or `due_soon.km` | book it with the next visit |
| CS03 | medium | no record of the item in the log: counted from first registration | check the service book / receipts / dealer record before paying for it |
| CS04 | high: date in the future, reading above the current odometer; medium: reading lower than an earlier one, unreadable odometer | log consistency | fix the typo; the reading is ignored for distance and due calculations |
| CS05 | info | last done at under 60% of both intervals | fine if intended (severe use, warranty); otherwise money spent early |
| CS06 | info | severe-service intervals in use | check the conditions still apply |
| CS07 | info | log item not in your schedule (repairs, extras) | kept for history; add aliases if it is a schedule item under another name |
| CS08 | low | item costing 400 or more falls inside the plan horizon | budget for it; get a quote early |
| CS09 | medium | not enough odometer readings (a reading at least 60 days back): default yearly distance used | add readings (test certificates, fuel app, photos of the dashboard) |

## Verdict
- **Overdue including safety/legal**: book now.
- **Overdue**: book soon.
- **Due soon** / **up to date**.

## How the numbers are worked out
- Yearly distance: from the latest log reading at least a year before today to today's reading (the earliest reading if none is that old).
- Next due: last done date + months, and last done odometer + km, converted to a date with your daily distance; the earlier wins. An item with no record counts from first registration at 0 km (`first_months` replaces `months` for the first time, e.g. a first legal test after 36 months).
- Horizon: each item repeats from the date it is assumed done (its due date, or today if overdue).
- Visits: items due within `bundle.days` and `bundle.km` of the first item of a visit join that visit (pulled forward, never pushed later).
- Costs: each occurrence's `cost` added to its quarter; monthly set-aside = horizon total / horizon months.

## Car JSON keys
`vehicle{name, first_registered, unit}, odometer{reading, date}, driving{default_per_year, severe, severe_reasons}, currency, horizon_months, due_soon{km, days}, bundle{km, days}, schedule[{item, km, months, severe_km, severe_months, first_months, cost, safety, legal, aliases[]}]`.

## Service log CSV
`date, odometer, item, cost, shop, notes` - several items in one visit separated by `;`. Names are matched to schedule items and aliases case-insensitively. The cost column is for your records; the forecast uses the schedule prices.
FILE:templates/service-plan.md
# Car service plan: <car, registration>

**Date:** <YYYY-MM-DD>   **Odometer:** <83,400 km>   **Yearly distance:** <about 11,000 km>   **Schedule:** <owner's manual, normal / severe service>

## Do now
| Item | Why | Est. cost |
|---|---|---|
| <Brake fluid> | <overdue by 4 months (2-year interval) - safety> | <85> |
| <Roadworthiness test> | <due 2026-11-20 - legal> | <60> |

## Next visits
| When (book by) | At about | Items | Est. cost |
|---|---|---|---|
| <2027-02-14> | <86,500 km> | <oil and filter, brake inspection> | <180> |

## Budget
- Next 12 months: <1,220> <EUR>
- Next 24 months: <2,445> <EUR> -> set aside <102> per month
- Big items ahead: <timing belt kit and water pump, Nov 2027, about 680>

## To check
- [ ] <no record of the timing belt - check the service book / ask the dealer>
- [ ] <exact brake fluid interval for this engine>
- [ ] <warranty: does it require dealer servicing?>

## Checklist for the workshop
- Car: <make, model, engine, VIN if asked>, odometer <...>
- Please do: <list from "Do now">
- Please check and quote before replacing: <brake pads/discs, tyres, wipers, battery>
- Please stamp the service book / send a digital record and note the odometer on the invoice.

*Intervals from <source>; costs are estimates. Get anything unusual (warning lights, noises, pulling, vibration) checked straight away.*
FILE:examples/car.json
{
  "vehicle": {"name": "2019 Skoda Octavia Estate 1.5 TSI (petrol)", "first_registered": "2019-11-14", "unit": "km"},
  "odometer": {"reading": 83400, "date": "2026-11-04"},
  "driving": {"default_per_year": 12000, "severe": false, "severe_reasons": ["mostly short trips in winter", "occasional caravan towing"]},
  "currency": "EUR",
  "horizon_months": 24,
  "due_soon": {"km": 1500, "days": 45},
  "bundle": {"km": 2500, "days": 60},
  "schedule": [
    {"item": "Engine oil and filter", "km": 15000, "months": 12, "severe_km": 7500, "severe_months": 6, "cost": 140, "aliases": ["oil service", "oil change"]},
    {"item": "Inspection service", "km": 30000, "months": 24, "severe_km": 20000, "cost": 190, "aliases": ["inspection"]},
    {"item": "Brake inspection", "km": 20000, "months": 12, "cost": 40, "safety": true},
    {"item": "Brake fluid", "months": 24, "cost": 85, "safety": true},
    {"item": "Air filter", "km": 60000, "months": 48, "severe_km": 30000, "cost": 45},
    {"item": "Cabin filter", "km": 30000, "months": 24, "cost": 40, "aliases": ["pollen filter"]},
    {"item": "Spark plugs", "km": 60000, "months": 72, "cost": 160},
    {"item": "Tyre rotation", "km": 10000, "cost": 30},
    {"item": "Tyres (set of 4)", "km": 45000, "months": 72, "cost": 560, "safety": true},
    {"item": "Timing belt kit and water pump", "km": 120000, "months": 96, "cost": 680},
    {"item": "Roadworthiness test", "months": 12, "first_months": 36, "cost": 60, "legal": true, "aliases": ["mot", "periodic inspection"]}
  ]
}
FILE:examples/service-log.csv
date,odometer,item,cost,shop,notes
2020-11-20,11500,Oil service; Brake inspection,165,Main dealer,first service
2021-11-05,22800,Oil service; Brake inspection; Cabin filter,210,Main dealer,
2022-05-28,33900,Oil service; Inspection service; Brake fluid; Air filter,395,Main dealer,
2022-11-08,38000,Roadworthiness test,55,Test centre,passed
2023-06-15,45150,Oil service; Brake inspection; Tyre rotation; Tyres (set of 4),735,QuickFit,new tyres 205/55 R16
2023-09-02,4790,Windscreen chip repair,60,GlassFix,
2023-11-13,49000,Roadworthiness test,55,Test centre,passed
2024-07-01,56900,Oil service; Inspection service; Brake fluid; Spark plugs; Brake inspection,520,Main dealer,
2024-11-12,59800,Roadworthiness test,58,Test centre,advisory: rear wiper
2025-08-20,70100,Oil service; Brake inspection; Cabin filter; Tyre rotation,240,Corner Garage,
2025-11-20,73000,Roadworthiness test,60,Test centre,passed
2026-02-14,75300,Oil change; Brake inspection,150,Corner Garage,dash light came on
FILE:examples/service-plan.md
# Service plan: 2019 Skoda Octavia Estate 1.5 TSI (petrol)

Today 2026-11-04, odometer 83,400 km. You drive about 11,008 km/year (2025-08-20 70,100 -> 2026-11-04 83,400). Normal-service intervals, plan horizon 24 months. Source: examples/service-log.csv.

**Verdict: 4 item(s) overdue, including safety/legal items - book a visit now**

Next 12 months: about 1,220.00 EUR; next 24 months: 2,445.00 EUR (set aside about 101.88 EUR per month). Findings: 1 high, 6 medium, 2 low, 3 info.

## Items

| Item | Interval | Last done | Due by date | Due at km | Next (projected) | Trigger | Status | Est. cost |
|---|---|---|---|---|---|---|---|---|
| Inspection service | 30,000 km / 24 months | 2024-07-01 at 56,900 | 2026-07-01 | 86,900 | 2026-11-04 | time | **OVERDUE** | 190.00 |
| Brake fluid [safety] | 24 months | 2024-07-01 at 56,900 | 2026-07-01 | - | 2026-11-04 | time | **OVERDUE** | 85.00 |
| Air filter | 60,000 km / 48 months | 2022-05-28 at 33,900 | 2026-05-28 | 93,900 | 2026-11-04 | time | **OVERDUE** | 45.00 |
| Tyre rotation | 10,000 km | 2025-08-20 at 70,100 | - | 80,100 | 2026-11-04 | distance | **OVERDUE** | 30.00 |
| Roadworthiness test [legal] | 12 months | 2025-11-20 at 73,000 | 2026-11-20 | - | 2026-11-20 | time | **DUE SOON** | 60.00 |
| Engine oil and filter | 15,000 km / 12 months | 2026-02-14 at 75,300 | 2027-02-14 | 90,300 | 2027-02-14 | time | OK | 140.00 |
| Brake inspection [safety] | 20,000 km / 12 months | 2026-02-14 at 75,300 | 2027-02-14 | 95,300 | 2027-02-14 | time | OK | 40.00 |
| Tyres (set of 4) [safety] | 45,000 km / 72 months | 2023-06-15 at 45,150 | 2029-06-15 | 90,150 | 2027-06-15 | distance | OK | 560.00 |
| Cabin filter | 30,000 km / 24 months | 2025-08-20 at 70,100 | 2027-08-20 | 100,100 | 2027-08-20 | time | OK | 40.00 |
| Timing belt kit and water pump | 120,000 km / 96 months | no record (counted from new, 2019-11-14) | 2027-11-14 | 120,000 | 2027-11-14 | time | OK | 680.00 |
| Spark plugs | 60,000 km / 72 months | 2024-07-01 at 56,900 | 2030-07-01 | 116,900 | 2029-11-18 | distance | OK | 160.00 |

## Workshop visits (items within 60 days / 2,500 km are bundled)

| # | Book by | At about | Items | Est. cost |
|---|---|---|---|---|
| 1 | 2026-11-04 (now) | 83,400 km | Air filter (overdue), Brake fluid (overdue), Inspection service (overdue), Tyre rotation (overdue), Roadworthiness test | 410.00 |
| 2 | 2027-02-14 | 86,476 km | Brake inspection, Engine oil and filter | 180.00 |
| 3 | 2027-06-15 | 90,125 km | Tyres (set of 4) | 560.00 |
| 4 | 2027-08-20 | 92,116 km | Cabin filter, Tyre rotation | 70.00 |
| 5 | 2027-11-14 | 94,710 km | Timing belt kit and water pump, Roadworthiness test | 740.00 |
| 6 | 2028-02-14 | 97,484 km | Brake inspection, Engine oil and filter | 180.00 |
| 7 | 2028-08-27 | 103,365 km | Tyre rotation | 30.00 |
| 8 | 2028-11-04 | 105,446 km | Brake fluid, Inspection service | 275.00 |

## Cost forecast by quarter

| Quarter | Est. cost |
|---|---|
| 2026-Q4 | 410.00 |
| 2027-Q1 | 180.00 |
| 2027-Q2 | 560.00 |
| 2027-Q3 | 40.00 |
| 2027-Q4 | 770.00 |
| 2028-Q1 | 180.00 |
| 2028-Q3 | 30.00 |
| 2028-Q4 | 275.00 |
| **Total 24 months** | **2,445.00** |

## Findings

- **HIGH CS01** Brake fluid is overdue by 126 days (safety item) - book it now
- **MEDIUM CS01** Air filter is overdue by 160 days - book it now
- **MEDIUM CS01** Inspection service is overdue by 126 days - book it now
- **MEDIUM CS01** Tyre rotation is overdue by 3,300 km - book it now
- **MEDIUM CS02** Roadworthiness test is due soon: 2026-11-20
- **MEDIUM CS03** Timing belt kit and water pump: no record in your log - counted from new; check the service book or receipts
- **MEDIUM CS04** row 7: odometer 4,790 on 2023-09-02 is lower than 45,150 on 2023-06-15 - typo or wrong car? reading ignored
- **LOW CS08** Timing belt kit and water pump (about 680.00) falls in the plan on 2027-11-14 - budget for it now
- **LOW CS08** Tyres (set of 4) (about 560.00) falls in the plan on 2027-06-15 - budget for it now
- **INFO CS05** Brake inspection was last done after 5,200 km / 6 months (under 60% of the interval) - fine for severe use, otherwise money spent early
- **INFO CS05** Engine oil and filter was last done after 5,200 km / 6 months (under 60% of the interval) - fine for severe use, otherwise money spent early
- **INFO CS07** row 7: 'Windscreen chip repair' is not in your schedule - kept in the history only

Intervals and prices come from your car file: check them against the owner's manual or service booklet for your exact engine, and follow your workshop if it finds wear earlier. Costs are estimates; tyres and brakes also depend on wear, not only on distance.
FILE:scripts/car_service_plan.py
#!/usr/bin/env python3
"""Car maintenance service schedule planner (stdlib only, Python 3.8+).

Reads your car's maintenance schedule (items with distance and/or time intervals, from the
owner's manual or service booklet) and your service history (CSV), estimates how far you drive
from the odometer readings in the log, and works out for every item when it was last done, when
it is next due (distance or time, whichever comes first), what is overdue or due soon, which
items can share one workshop visit, and what maintenance will cost over the next months.

Usage:
  car_service_plan.py CAR.json SERVICE_LOG.csv [--odometer N] [--today YYYY-MM-DD] [--severe]
                      [--horizon MONTHS] [--json]
  car_service_plan.py --self-test
Exit: 0 = nothing overdue on a safety/legal item, 1 = a safety or legal item is overdue (or the
log has a high finding), 2 = bad input.
"""
import argparse, calendar, csv, datetime, io, json, sys

SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
CAR_KEYS = {"vehicle", "odometer", "driving", "currency", "horizon_months", "due_soon", "bundle", "schedule"}


class InputError(Exception):
    pass


def pdate(s, what):
    try:
        return datetime.date.fromisoformat(str(s).strip())
    except ValueError:
        raise InputError("%s must be YYYY-MM-DD, got %r" % (what, s))


def add_months(d, m):
    y, mo = divmod(d.month - 1 + int(m), 12)
    y += d.year
    mo += 1
    return datetime.date(y, mo, min(d.day, calendar.monthrange(y, mo)[1]))


def num(x, what):
    try:
        return float(str(x).replace(",", "").replace(" ", ""))
    except ValueError:
        raise InputError("%s is not a number: %r" % (what, x))


def fk(x):
    return "{:,.0f}".format(x)


def fm(x):
    return "{:,.2f}".format(x)


# ---------------------------------------------------------------- load
def load_car(cfg, odometer=None, today=None, severe=None, horizon=None):
    if not isinstance(cfg, dict):
        raise InputError("car file must be a JSON object")
    bad = set(cfg) - CAR_KEYS
    if bad:
        raise InputError("unknown key(s) in car file: %s" % ", ".join(sorted(bad)))
    v = cfg.get("vehicle") or {}
    odo = cfg.get("odometer") or {}
    drv = cfg.get("driving") or {}
    c = {"name": v.get("name", "Car"), "unit": v.get("unit", "km"),
         "first": pdate(v["first_registered"], "vehicle.first_registered") if v.get("first_registered") else None,
         "today": pdate(today or odo.get("date") or datetime.date.today().isoformat(), "today"),
         "odo": num(odometer if odometer is not None else odo.get("reading"), "odometer reading") if (odometer is not None or odo.get("reading") is not None) else None,
         "default_per_year": num(drv.get("default_per_year", 12000), "driving.default_per_year"),
         "severe": bool(drv.get("severe", False)) if severe is None else severe,
         "severe_reasons": drv.get("severe_reasons", []),
         "currency": cfg.get("currency", ""),
         "horizon": int(horizon or cfg.get("horizon_months", 24)),
         "soon_km": num((cfg.get("due_soon") or {}).get("km", 1500), "due_soon.km"),
         "soon_days": int((cfg.get("due_soon") or {}).get("days", 45)),
         "bundle_km": num((cfg.get("bundle") or {}).get("km", 2500), "bundle.km"),
         "bundle_days": int((cfg.get("bundle") or {}).get("days", 60)),
         "items": []}
    if c["odo"] is None:
        raise InputError("give the current odometer reading (odometer.reading or --odometer)")
    for i, it in enumerate(cfg.get("schedule") or [], 1):
        name = it.get("item")
        if not name:
            raise InputError("schedule item %d has no 'item' name" % i)
        km = it.get("severe_km") if c["severe"] and it.get("severe_km") else it.get("km")
        mo = it.get("severe_months") if c["severe"] and it.get("severe_months") else it.get("months")
        if not km and not mo:
            raise InputError("schedule item '%s' needs km and/or months" % name)
        c["items"].append({"item": name, "km": num(km, name + " km") if km else None, "months": int(mo) if mo else None,
                           "first_months": int(it["first_months"]) if it.get("first_months") else None,
                           "cost": num(it.get("cost", 0), name + " cost"), "safety": bool(it.get("safety")), "legal": bool(it.get("legal")),
                           "severe_used": bool(c["severe"] and (it.get("severe_km") or it.get("severe_months"))),
                           "aliases": [a.lower().strip() for a in it.get("aliases", [])] + [name.lower().strip()]})
    if not c["items"]:
        raise InputError("car file needs a non-empty 'schedule'")
    return c


def load_log(c, text, F):
    rows = list(csv.DictReader(io.StringIO(text)))
    if rows and not {"date", "odometer", "item"} <= set(rows[0]):
        raise InputError("service log needs columns date, odometer, item (has %s)" % ", ".join(rows[0]))
    entries, readings = [], []
    for n, r in enumerate(rows, 2):
        try:
            d = pdate(r.get("date"), "date (row %d)" % n)
        except InputError as e:
            F.append(("high", "CS04", "row %d: %s - row ignored" % (n, e))); continue
        if d > c["today"]:
            F.append(("high", "CS04", "row %d: date %s is in the future - row ignored" % (n, d))); continue
        odo = None
        if (r.get("odometer") or "").strip():
            try:
                odo = num(r["odometer"], "odometer (row %d)" % n)
            except InputError as e:
                F.append(("medium", "CS04", "%s - distance ignored for this row" % e))
        items = [x.strip() for x in (r.get("item") or "").split(";") if x.strip()]
        entries.append({"row": n, "date": d, "odo": odo, "items": items, "cost": r.get("cost", ""), "shop": r.get("shop", "")})
    entries.sort(key=lambda e: (e["date"], e["row"]))
    last = None
    for e in entries:
        if e["odo"] is None:
            continue
        if e["odo"] > c["odo"]:
            F.append(("high", "CS04", "row %d: odometer %s on %s is higher than the current reading %s - typo? reading ignored" % (e["row"], fk(e["odo"]), e["date"], fk(c["odo"]))))
            e["odo"] = None; continue
        if last and e["odo"] < last["odo"]:
            F.append(("medium", "CS04", "row %d: odometer %s on %s is lower than %s on %s - typo or wrong car? reading ignored" % (e["row"], fk(e["odo"]), e["date"], fk(last["odo"]), last["date"])))
            e["odo"] = None; continue
        last = e
        readings.append((e["date"], e["odo"]))
    readings.append((c["today"], c["odo"]))
    return entries, readings


def distance_rate(c, readings, F):
    """km per day from the latest reading at least a year before today (else the earliest)."""
    end_d, end_o = readings[-1]
    older = [r for r in readings[:-1] if (end_d - r[0]).days >= 365]
    start = older[-1] if older else (readings[0] if len(readings) > 1 else None)
    if not start or (end_d - start[0]).days < 60 or end_o <= start[1]:
        F.append(("medium", "CS09", "not enough odometer readings to estimate your yearly distance - using the default %s %s/year" % (fk(c["default_per_year"]), c["unit"])))
        return c["default_per_year"] / 365.0, "default"
    rate = (end_o - start[1]) / float((end_d - start[0]).days)
    return rate, "%s %s -> %s %s" % (start[0], fk(start[1]), end_d, fk(end_o))


# ---------------------------------------------------------------- plan
def plan(c, log_text):
    F = []
    entries, readings = load_log(c, log_text, F)
    rate, basis = distance_rate(c, readings, F)
    known = {a: it for it in c["items"] for a in it["aliases"]}
    done = {it["item"]: [] for it in c["items"]}
    for e in entries:
        for name in e["items"]:
            it = known.get(name.lower())
            if not it:
                F.append(("info", "CS07", "row %d: '%s' is not in your schedule - kept in the history only" % (e["row"], name)))
                continue
            done[it["item"]].append(e)
    def date_at_km(km):
        return c["today"] + datetime.timedelta(days=max(0.0, (km - c["odo"]) / rate)) if rate > 0 else None
    def km_at_date(d):
        return c["odo"] + rate * (d - c["today"]).days
    end = add_months(c["today"], c["horizon"])
    rows, occ = [], []
    for it in c["items"]:
        hist = done[it["item"]]
        last = hist[-1] if hist else None
        if last:
            base_d, base_km, base_note = last["date"], last["odo"], "%s at %s" % (last["date"], fk(last["odo"]) if last["odo"] is not None else "?")
        else:
            if not c["first"]:
                raise InputError("no record of '%s' and no vehicle.first_registered to count from" % it["item"])
            base_d, base_km, base_note = c["first"], 0.0, "no record (counted from new, %s)" % c["first"]
            F.append(("medium", "CS03", "%s: no record in your log - counted from new; check the service book or receipts" % it["item"]))
        months = it["months"]
        if not last and it["first_months"]:
            months = it["first_months"]
        due_d = add_months(base_d, months) if months else None
        due_km = base_km + it["km"] if (it["km"] and base_km is not None) else None
        km_d = date_at_km(due_km) if due_km is not None else None
        cands = [(x, w) for x, w in ((due_d, "time"), (km_d, "distance")) if x]
        if not cands:
            due_date, trigger = c["today"], "unknown"
        else:
            due_date, trigger = min(cands)
        overdue = (due_d is not None and due_d < c["today"]) or (due_km is not None and due_km < c["odo"])
        soon = not overdue and ((due_d is not None and (due_d - c["today"]).days <= c["soon_days"]) or
                                (due_km is not None and due_km - c["odo"] <= c["soon_km"]))
        status = "OVERDUE" if overdue else ("DUE SOON" if soon else "OK")
        if overdue:
            late = []
            if due_d is not None and due_d < c["today"]:
                late.append("%d days" % (c["today"] - due_d).days)
            if due_km is not None and due_km < c["odo"]:
                late.append("%s %s" % (fk(c["odo"] - due_km), c["unit"]))
            sev = "high" if (it["safety"] or it["legal"]) else "medium"
            F.append((sev, "CS01", "%s is overdue by %s%s - book it now" % (it["item"], " and ".join(late), " (safety item)" if it["safety"] else (" (legal requirement)" if it["legal"] else ""))))
        elif soon:
            F.append(("medium", "CS02", "%s is due soon: %s%s" % (it["item"], due_d if due_d and (due_d - c["today"]).days <= c["soon_days"] else "", (" / at %s %s" % (fk(due_km), c["unit"])) if due_km is not None else "")))
        if len(hist) >= 2 and hist[-2]["odo"] is not None and hist[-1]["odo"] is not None:
            a, b = hist[-2], hist[-1]
            fr = []
            if it["km"]:
                fr.append((b["odo"] - a["odo"]) / it["km"])
            if it["months"]:
                fr.append(((b["date"] - a["date"]).days / 30.44) / it["months"])
            if fr and max(fr) < 0.6:
                F.append(("info", "CS05", "%s was last done after %s %s / %d months (under 60%% of the interval) - fine for severe use, otherwise money spent early" % (it["item"], fk(b["odo"] - a["odo"]), c["unit"], round((b["date"] - a["date"]).days / 30.44))))
        rows.append({"item": it["item"], "last": base_note, "interval": " / ".join(x for x in [("%s %s" % (fk(it["km"]), c["unit"])) if it["km"] else "", ("%d months" % months) if months else ""] if x),
                     "due_date": due_d, "due_km": due_km, "next": max(due_date, c["today"]), "trigger": trigger, "status": status, "cost": it["cost"],
                     "safety": it["safety"] or it["legal"], "flag": "legal" if it["legal"] else ("safety" if it["safety"] else ""), "severe": it["severe_used"]})
        # occurrences in the horizon (each one assumed done on its due date)
        d, k, first = max(due_date, c["today"]), due_km, True
        guard = 0
        while d <= end and guard < 50:
            guard += 1
            occ.append({"item": it["item"], "date": d, "km": km_at_date(d), "cost": it["cost"], "overdue": first and overdue})
            first = False
            nd = add_months(d, it["months"]) if it["months"] else None
            nk = km_at_date(d) + it["km"] if it["km"] else None
            cands = [x for x in (nd, date_at_km(nk) if nk is not None else None) if x]
            if not cands:
                break
            d = min(cands)
        if (it["cost"] >= 400) and any(o["item"] == it["item"] for o in occ):
            o = next(o for o in occ if o["item"] == it["item"])
            F.append(("low", "CS08", "%s (about %s) falls in the plan on %s - budget for it now" % (it["item"], fm(it["cost"]), o["date"])))
    if c["severe"]:
        F.append(("info", "CS06", "severe-service intervals used%s" % ((" (" + ", ".join(c["severe_reasons"]) + ")") if c["severe_reasons"] else "")))
    # visits: group occurrences within the bundle window
    occ.sort(key=lambda o: (o["date"], o["item"]))
    visits = []
    for o in occ:
        v = visits[-1] if visits else None
        if v and (o["date"] - v["date"]).days <= c["bundle_days"] and o["km"] - v["km"] <= c["bundle_km"]:
            v["items"].append(o); continue
        visits.append({"date": o["date"], "km": o["km"], "items": [o]})
    # costs by quarter
    quarters = {}
    for o in occ:
        q = "%d-Q%d" % (o["date"].year, (o["date"].month - 1) // 3 + 1)
        quarters[q] = quarters.get(q, 0.0) + o["cost"]
    y1 = sum(o["cost"] for o in occ if o["date"] < add_months(c["today"], 12))
    total = sum(o["cost"] for o in occ)
    F.sort(key=lambda f: (SEV[f[0]], f[1], f[2]))
    return {"car": c, "rate": rate, "basis": basis, "rows": rows, "occ": occ, "visits": visits, "quarters": quarters,
            "next12": y1, "total": total, "findings": F, "entries": entries}


# ---------------------------------------------------------------- report
def report(res, title):
    c, F = res["car"], res["findings"]
    cnt = {s: sum(1 for f in F if f[0] == s) for s in SEV}
    cur = (" " + c["currency"]) if c["currency"] else ""
    ov = [r for r in res["rows"] if r["status"] == "OVERDUE"]
    soon = [r for r in res["rows"] if r["status"] == "DUE SOON"]
    L = ["# Service plan: %s" % c["name"], ""]
    L.append("Today %s, odometer %s %s. You drive about %s %s/year (%s). %s intervals, plan horizon %d months. Source: %s." % (
        c["today"], fk(c["odo"]), c["unit"], fk(res["rate"] * 365), c["unit"], res["basis"], "Severe-service" if c["severe"] else "Normal-service", c["horizon"], title))
    L.append("")
    if any(r["safety"] for r in ov):
        L.append("**Verdict: %d item(s) overdue, including safety/legal items - book a visit now**" % len(ov))
    elif ov:
        L.append("**Verdict: %d item(s) overdue - book a visit soon**" % len(ov))
    elif soon:
        L.append("**Verdict: nothing overdue, %d item(s) due soon**" % len(soon))
    else:
        L.append("**Verdict: up to date**")
    L.append("")
    L.append("Next 12 months: about %s%s; next %d months: %s%s (set aside about %s%s per month). Findings: %d high, %d medium, %d low, %d info." % (
        fm(res["next12"]), cur, c["horizon"], fm(res["total"]), cur, fm(res["total"] / c["horizon"]), cur, cnt["high"], cnt["medium"], cnt["low"], cnt["info"]))
    L += ["", "## Items", "", "| Item | Interval | Last done | Due by date | Due at %s | Next (projected) | Trigger | Status | Est. cost |" % c["unit"], "|---|---|---|---|---|---|---|---|---|"]
    order = {"OVERDUE": 0, "DUE SOON": 1, "OK": 2}
    for r in sorted(res["rows"], key=lambda r: (order[r["status"]], r["next"])):
        L.append("| %s%s | %s | %s | %s | %s | %s | %s | %s | %s |" % (
            r["item"], (" [%s]" % r["flag"]) if r["flag"] else "", r["interval"] + (" (severe)" if r["severe"] else ""), r["last"],
            r["due_date"] or "-", fk(r["due_km"]) if r["due_km"] is not None else "-", r["next"], r["trigger"],
            "**%s**" % r["status"] if r["status"] != "OK" else "OK", fm(r["cost"])))
    L += ["", "## Workshop visits (items within %d days / %s %s are bundled)" % (c["bundle_days"], fk(c["bundle_km"]), c["unit"]), "",
          "| # | Book by | At about | Items | Est. cost |", "|---|---|---|---|---|"]
    for i, v in enumerate(res["visits"], 1):
        L.append("| %d | %s%s | %s %s | %s | %s |" % (i, v["date"], " (now)" if v["date"] == c["today"] else "", fk(v["km"]), c["unit"],
                                                    ", ".join(o["item"] + (" (overdue)" if o["overdue"] else "") for o in v["items"]), fm(sum(o["cost"] for o in v["items"]))))
    L += ["", "## Cost forecast by quarter", "", "| Quarter | Est. cost |", "|---|---|"]
    for q in sorted(res["quarters"]):
        L.append("| %s | %s |" % (q, fm(res["quarters"][q])))
    L.append("| **Total %d months** | **%s** |" % (c["horizon"], fm(res["total"])))
    L += ["", "## Findings", ""]
    L += ["- **%s %s** %s" % (s.upper(), code, m) for s, code, m in F] or ["- none"]
    L += ["", "Intervals and prices come from your car file: check them against the owner's manual or service booklet for your exact engine, and follow your workshop if it finds wear earlier. Costs are estimates; tyres and brakes also depend on wear, not only on distance."]
    return "\n".join(L) + "\n"


def to_json(res):
    def j(x):
        if isinstance(x, datetime.date):
            return x.isoformat()
        if isinstance(x, float):
            return round(x, 2)
        if isinstance(x, dict):
            return {k: j(v) for k, v in x.items()}
        if isinstance(x, list):
            return [j(v) for v in x]
        return x
    return j({"km_per_year": res["rate"] * 365, "items": res["rows"], "visits": [{"date": v["date"], "km": v["km"], "items": [o["item"] for o in v["items"]],
              "cost": sum(o["cost"] for o in v["items"])} for v in res["visits"]], "quarters": res["quarters"], "next12": res["next12"], "total": res["total"],
              "findings": [{"severity": s, "code": c, "message": m} for s, c, m in res["findings"]]})


# ---------------------------------------------------------------- self-test
def self_test():
    tests = []
    def t(name, cond):
        tests.append((name, bool(cond)))
    def car(**kw):
        cfg = {"vehicle": {"name": "T", "first_registered": "2020-01-01"}, "odometer": {"reading": 50000, "date": "2026-01-01"},
               "schedule": [{"item": "Oil", "km": 15000, "months": 12, "severe_km": 7500, "severe_months": 6, "cost": 100, "aliases": ["oil change"]}]}
        sched = kw.pop("schedule", None)
        if sched:
            cfg["schedule"] = sched
        cfg.update(kw)
        return cfg
    H = "date,odometer,item,cost,shop\n"
    def run(cfg, log, **kw):
        return plan(load_car(cfg, **kw), H + log)
    def row(res, name):
        return next(r for r in res["rows"] if r["item"] == name)
    def codes(res):
        return [(f[0], f[1]) for f in res["findings"]]
    log = "2025-01-01,40000,Oil,90,X\n"
    r = run(car(), log)
    t("rate from readings: 10000 km/year", abs(r["rate"] * 365 - 10000) < 1)
    t("due by time first (12 months)", row(r, "Oil")["due_date"] == datetime.date(2026, 1, 1) and row(r, "Oil")["trigger"] == "time")
    t("due today counts as due soon", row(r, "Oil")["status"] == "DUE SOON")
    r = run(car(), "2025-06-01,40000,Oil,90,X\n")
    t("distance triggers first when driving a lot", row(r, "Oil")["due_km"] == 55000 and row(r, "Oil")["trigger"] == "distance")
    r = run(car(), "2024-06-01,30000,Oil,90,X\n")
    t("overdue by time and distance -> CS01", row(r, "Oil")["status"] == "OVERDUE" and ("medium", "CS01") in codes(r))
    sched = [{"item": "Brake fluid", "months": 24, "cost": 80, "safety": True}]
    r = run(car(schedule=sched), "2023-06-01,30000,Brake fluid,80,X\n2025-01-01,40000,Other,0,X\n")
    t("overdue safety item -> CS01 high", ("high", "CS01") in codes(r))
    t("item not in schedule -> CS07", ("info", "CS07") in codes(r))
    r = run(car(), "2025-01-01,40000,oil change,90,X\n")
    t("aliases match case-insensitively", row(r, "Oil")["last"].startswith("2025-01-01"))
    r = run(car(), "2025-01-01,40000,Wipers,20,X\n")
    t("no record -> counted from new, CS03", ("medium", "CS03") in codes(r) and "no record" in row(r, "Oil")["last"])
    r = run(car(), "2025-01-01,40000,Oil,90,X\n", severe=True)
    t("severe intervals used", row(r, "Oil")["due_date"] == datetime.date(2025, 7, 1) and ("info", "CS06") in codes(r))
    r = run(car(), "2024-01-01,30000,Oil,90,X\n2024-06-01,20000,Oil,90,X\n2025-01-01,40000,Oil,90,X\n")
    t("odometer going down -> CS04, reading ignored", ("medium", "CS04") in codes(r))
    r = run(car(), "2025-01-01,40000,Oil,90,X\n2027-01-01,60000,Oil,90,X\n")
    t("future date -> CS04 high", ("high", "CS04") in codes(r))
    r = run(car(), "2025-01-01,90000,Oil,90,X\n")
    t("reading above current odometer -> CS04 high", ("high", "CS04") in codes(r))
    r = run(car(), "")
    t("no readings -> default rate + CS09", ("medium", "CS09") in codes(r) and abs(r["rate"] * 365 - 12000) < 1)
    r = run(car(), "2025-01-01,40000,Oil,90,X\n2025-04-01,42000,Oil,90,X\n")
    t("done early -> CS05", ("info", "CS05") in codes(r))
    sched = [{"item": "Test", "months": 12, "first_months": 36, "cost": 50, "legal": True}]
    r = run(car(vehicle={"first_registered": "2024-01-01"}, schedule=sched), "2025-01-01,40000,Other,0,X\n")
    t("first_months for first test from new", row(r, "Test")["due_date"] == datetime.date(2027, 1, 1))
    sched = [{"item": "A", "months": 12, "cost": 100}, {"item": "B", "months": 12, "cost": 50}]
    r = run(car(schedule=sched), "2025-02-01,41000,A,0,X\n2025-03-15,42000,B,0,X\n")
    t("items within the bundle window share a visit", len(r["visits"][0]["items"]) == 2)
    t("horizon repeats yearly items", sum(1 for o in r["occ"] if o["item"] == "A") == 2)
    t("12-month and total forecast", r["next12"] == 150 and r["total"] == 300)
    sched = [{"item": "Belt", "km": 120000, "months": 96, "cost": 650}]
    r = run(car(schedule=sched), "2025-01-01,40000,Other,0,X\n")
    t("big-ticket item in horizon -> CS08", ("low", "CS08") in codes(r))
    t("add_months clamps month end", add_months(datetime.date(2026, 1, 31), 1) == datetime.date(2026, 2, 28))
    try:
        load_car(car(schedule=[{"item": "X", "cost": 1}])); ok = False
    except InputError:
        ok = True
    t("item without interval rejected", ok)
    try:
        load_car(dict(car(), colour="red")); ok = False
    except InputError:
        ok = True
    t("unknown key rejected", ok)
    r = run(car(), log)
    t("report has verdict, visits and forecast", all(s in report(r, "t") for s in ("**Verdict:", "## Workshop visits", "## Cost forecast")))
    for name, ok in tests:
        print(("PASS  " if ok else "FAIL  ") + name)
    passed = sum(ok for _, ok in tests)
    print("%d/%d passed" % (passed, len(tests)))
    return 0 if passed == len(tests) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Plan car maintenance from a schedule and a service log.")
    ap.add_argument("car", nargs="?")
    ap.add_argument("log", nargs="?")
    ap.add_argument("--odometer", type=float)
    ap.add_argument("--today")
    ap.add_argument("--severe", action="store_true", default=None, help="use severe-service intervals")
    ap.add_argument("--horizon", type=int, help="months to plan (default from car file, else 24)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.car or not a.log:
        ap.print_usage(sys.stderr)
        return 2
    try:
        try:
            cfg = json.load(open(a.car, encoding="utf-8"))
        except json.JSONDecodeError as e:
            raise InputError("car file is not valid JSON: %s" % e)
        c = load_car(cfg, a.odometer, a.today, a.severe, a.horizon)
        res = plan(c, open(a.log, encoding="utf-8-sig").read())
    except (OSError, InputError) as e:
        print("error: %s" % e, file=sys.stderr)
        return 2
    if a.json:
        print(json.dumps(to_json(res), indent=2))
    else:
        sys.stdout.write(report(res, a.log))
    return 1 if any(f[0] == "high" for f in res["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonPlanningBudgeting+2
F@f
0
systemd 服务单元加固审查
技能

审查 .service 与 drop-in:禁止 root 乱跑、Environment 泄密、权限过宽等。

---
name: systemd-service-unit-hardening-reviewer
description: 审查 .service 与 drop-in:禁止 root 乱跑、Environment 泄密、权限过宽等。
---

# systemd 服务单元加固审查

你是「systemd 服务单元加固审查」技能助手。上方 description 为中文任务说明。下方保留原技能完整正文(含脚本与代码);执行时遵循中文说明与原文技术细节。

# systemd Service Unit Hardening Reviewer

You help developers and admins ship systemd service units that start reliably and do not hand an attacker the whole machine. Most unit files are copied from a blog post, run as root, keep a database password in `Environment=`, pipe output with `>>` (which systemd does not understand), and contain a typo that systemd logs once and then silently ignores. Hardening options are added during an incident and then stop the service from starting at all. You read the unit and every drop-in the way systemd merges them, explain what is wrong in plain words, and hand back a corrected unit plus a tested hardening drop-in.

You review the text you are given. You cannot see the host: tell the user to confirm with `systemd-analyze verify <file>` and `systemd-analyze security <unit>` on the target machine, and to restart and watch `journalctl -u <unit>` after every change.

## Files in this skill

- `scripts/systemd_unit_review.py` - parses a .service file plus drop-in .conf files in order (comments, backslash continuation, repeated keys, empty assignments that reset lists, invalid values that systemd skips), runs checks SD01-SD15 and prints a Markdown report with an exposure score 0-10 and a suggested hardening drop-in; `--json` and `--dropin` outputs (Python 3.8+ standard library only)
- `references/directives-and-sandboxing.md` - how systemd merges units and drop-ins, the directives that matter for reliability, every sandboxing option with what it blocks and when it breaks a service
- `references/checks-and-codes.md` - every finding code, its severity and the fix
- `templates/unit-review.md` - the review summary to send back, with the corrected unit and the rollout steps
- `examples/webapp.service` + `examples/webapp-override.conf` - a hand-deployed Node.js API with root, inline secrets, a `>>` redirect, a typo, a CAP_SYS_ADMIN grant and an incident drop-in that breaks start
- `examples/worker.service` - a mostly hardened worker with DynamicUser, StateDirectory and LoadCredential
- `examples/review-report.md` - the real checker output on both units

## Workflow

### 1. Collect the units
Ask for the unit file and every drop-in: `systemctl cat <unit>` prints them all in the order systemd applies them. Ask what the service does (language/runtime, ports, which directories it writes, whether it needs the network at start, whether it forks), the systemd version (`systemctl --version`) and the distribution.

### 2. Run the checker
```
python3 scripts/systemd_unit_review.py examples/webapp.service examples/webapp-override.conf examples/worker.service
python3 scripts/systemd_unit_review.py myapp.service override.conf --dropin
python3 scripts/systemd_unit_review.py myapp.service --json
python3 scripts/systemd_unit_review.py --self-test
```
Files after a .service are its drop-ins until the next .service. Exit 0 = no high findings, 1 = high findings, 2 = bad input. Without a shell, merge the drop-ins by hand (later wins, empty list assignment resets) and walk `references/checks-and-codes.md`.

### 3. Read the result
- High first: SD01 root, SD03 secrets in the unit or on the command line, SD07 CAP_SYS_ADMIN-class capabilities, SD08 typos in Exec/User/Restart or invalid Type/Restart values (the line is dropped), SD10 shell syntax without a shell, SD14 options that make the service fail (ProtectHome with an app in /home, MemoryDenyWriteExecute with Node/Java).
- Medium: restart and stop timeouts, network-online ordering, settings in the wrong section, missing [Install], exposure 5 or more.
- Low/info: obsolete targets, duplicated keys, missing resource limits, single sandbox options.

### 4. Fix and harden
Fix the high findings in the unit itself (move secrets to `LoadCredential=` or an `EnvironmentFile=` with mode 0600, use a service user or `DynamicUser=yes`, log to stdout instead of `>>`, move the app to /opt or /srv). Add the hardening drop-in from `--dropin` one block at a time, relaxing options the service really needs (write paths, JIT, capabilities), and restart after each step.

### 5. Deliver
Fill `templates/unit-review.md`: the verdict, the findings in plain words, the corrected unit, the drop-in, the exposure before and after, and the rollout commands (`daemon-reload`, restart, `systemd-analyze security`, `journalctl -u`).

## Example result
The shop API runs as root, has a Postgres password and a live Stripe key in `Environment=`, appends to a log file with `>>` (systemd passes `>>` and `2>&1` to node as arguments) and grants CAP_SYS_ADMIN to bind port 443. `RestartSecs=5` is a typo, so restarts come every 100 ms. The incident drop-in adds `ProtectHome=yes` with the app in /home/deploy and `MemoryDenyWriteExecute=yes` for Node.js - both stop it from starting - and its `Restart=on-faliure` is ignored. 9 high findings, exposure 8.1. The worker is in good shape (exposure 1.2, six optional options to add).

## Rules

- Never print or repeat a secret value found in a unit; name the variable and tell the user to rotate it, since it was readable by every local user.
- Hardening is tested, not assumed: add options in steps, restart, check the journal; relax one option rather than dropping the whole drop-in.
- Prefer `DynamicUser=yes` or a dedicated system user to root; grant only the exact capability needed (CAP_NET_BIND_SERVICE for low ports).
- Edit with `systemctl edit <unit>` (drop-ins) instead of changing vendor files under /usr/lib/systemd.
- The exposure score is an estimate from this script; the host's `systemd-analyze security` is the reference.
FILE:references/directives-and-sandboxing.md
# Directives and sandboxing notes

## How systemd builds the unit
- Files: the main unit (/etc/systemd/system, else /usr/lib/systemd/system) then drop-ins `<unit>.d/*.conf` in name order. `systemctl cat <unit>` shows them all.
- Later assignments win for single-value keys. List keys (After=, Environment=, ExecStartPre=, ReadWritePaths= ...) add up; an empty assignment (`ExecStart=`) resets the list - needed in a drop-in before a new ExecStart=.
- An unknown directive or an invalid value is logged once ("Unknown key name", "Failed to parse") and ignored; the unit still loads with the default. That is why typos survive for years.
- Lines ending in `\` continue on the next line. `#` and `;` start comments only at the start of a line.
- `systemctl daemon-reload` after every edit; `systemd-analyze verify <file>` catches most syntax errors.

## Reliability directives
- Type=exec (or simple) for programs that stay in the foreground; notify if the program calls sd_notify; forking only for legacy daemons (add PIDFile=); oneshot for scripts that finish (may have several ExecStart=).
- Restart=on-failure + RestartSec=5s for most services. Restart=always also restarts after a clean exit. Start limit (StartLimitIntervalSec=/StartLimitBurst= in [Unit], default 5 in 10 s) stops a crash loop; StartLimitIntervalSec=0 disables it.
- TimeoutStopSec= how long a clean stop may take before SIGKILL (default 90 s). infinity hangs shutdowns.
- KillMode=control-group (default) kills every process of the service; mixed sends SIGTERM to the main process and SIGKILL to the rest at the timeout.
- Network: `Wants=network-online.target` + `After=network-online.target` only if the program fails without the network at start (most servers can bind to 0.0.0.0 without it).
- Logs: write to stdout/stderr; journald stores them (`journalctl -u <unit>`). Use LogsDirectory= if a file is required.
- [Install] WantedBy=multi-user.target so `systemctl enable` starts it at boot.

## Exec lines are not shell
systemd splits the line into words itself: `|`, `>`, `>>`, `2>&1`, `&&`, `;`, `&`, `$(...)` and backticks are passed to the program as arguments. `$VAR` and `VAR` are expanded from Environment=. If you need a shell use `ExecStart=/bin/sh -c '...'`, but a small script is easier to read and test. Prefixes: `-` ignore failure, `@` argv0, `+` full privileges, `!` privileges without the user switch (both skip the sandbox).

## Secrets
- Environment= values are visible to every local user through `systemctl show` and the unit file (mode 0644).
- `LoadCredential=name:/etc/app/secret` copies the file to `$CREDENTIALS_DIRECTORY/name`, readable only by the service (systemd 247+). `SetCredentialEncrypted=` / `systemd-creds` for encrypted ones (250+).
- EnvironmentFile=/etc/app/env with owner root and mode 0600 is the portable fallback.
- Command-line secrets are visible in `ps` and /proc/<pid>/cmdline.

## Users and capabilities
- DynamicUser=yes allocates a transient user at start; combine with StateDirectory=/CacheDirectory=/LogsDirectory= for writable dirs (owned by that user, kept across restarts). Implies ProtectSystem=strict, ProtectHome=read-only, PrivateTmp.
- Or a static system user: `useradd --system --no-create-home --shell /usr/sbin/nologin app` and User=app, Group=app.
- Port below 1024 as non-root: `AmbientCapabilities=CAP_NET_BIND_SERVICE` and `CapabilityBoundingSet=CAP_NET_BIND_SERVICE`. CAP_SYS_ADMIN is almost root - never for a web app.

## Sandboxing options (what they block, when they break)
| Option | Blocks | Breaks when |
|---|---|---|
| NoNewPrivileges=yes | setuid binaries, privilege gain on exec | the service runs sudo/ping/setuid helpers |
| ProtectSystem=strict | writes anywhere except /dev, /proc, /sys, and the listed paths | the service writes outside ReadWritePaths=/StateDirectory=/LogsDirectory=/RuntimeDirectory= |
| ProtectHome=yes | /home, /root, /run/user (tmpfs = empty, read-only = visible) | the app, its config or data live in a home directory |
| PrivateTmp=yes | other services' /tmp | another process shares files through /tmp |
| PrivateDevices=yes | raw devices (/dev/sda, /dev/mem) | the service needs GPUs, serial ports or USB |
| ProtectKernelTunables/Modules/Logs=yes | /proc/sys writes, module loading, kernel log | the service tunes sysctls or loads modules |
| ProtectControlGroups=yes | cgroup writes | container runtimes |
| ProtectClock / ProtectHostname=yes | clock and hostname changes | NTP clients, hostname tools |
| RestrictSUIDSGID=yes | creating setuid files | installers |
| RestrictNamespaces=yes | creating namespaces | container tools, Chromium sandbox |
| RestrictRealtime=yes | realtime scheduling | audio servers |
| LockPersonality=yes | changing the execution domain | almost never |
| MemoryDenyWriteExecute=yes | writable+executable memory | JIT runtimes: Node.js, Java, .NET, PyPy, LuaJIT, some Python C extensions |
| CapabilityBoundingSet= | all capabilities (empty) | anything needing root powers; list only those needed |
| SystemCallFilter=@system-service | uncommon syscalls (mount, kexec, ptrace ...) | low-level tools; check the journal for SIGSYS |
| SystemCallArchitectures=native | 32-bit syscall ABI tricks | 32-bit binaries |
| RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 | netlink, packet, bluetooth sockets | tools that read routing tables (add AF_NETLINK) |
| UMask=0077 | world-readable new files | other users must read the files |

Rollout: apply in two or three steps (filesystem options, then kernel/namespace options, then syscalls and capabilities), restart, run the health check, read `journalctl -u <unit> -b`, then `systemd-analyze security <unit>`.
FILE:references/checks-and-codes.md
# Checks and finding codes

| Code | Severity | Check | Fix |
|---|---|---|---|
| SD01 | high (medium for oneshot) | runs as root: no User= and no DynamicUser=, or User=root; `+`/`!` Exec prefixes bypass the user and sandbox (medium) | DynamicUser=yes or a system user; drop the prefix |
| SD02 | high: no ExecStart, several ExecStart for a non-oneshot; low: command not an absolute path | ExecStart sanity | one absolute ExecStart; empty `ExecStart=` first in a drop-in to replace it |
| SD03 | high | secret-looking variable (PASSWORD, SECRET, TOKEN, API_KEY, PRIVATE_KEY, DSN) or a URL with user:password in Environment=; `--password=` style secrets on the command line | LoadCredential= or EnvironmentFile= with mode 0600; rotate the leaked secret |
| SD04 | medium: Restart=no for a long-running service; low: no RestartSec (100 ms default); high: oneshot with Restart=always/on-success; info: Restart=always with the default start limit | restart policy | Restart=on-failure, RestartSec=5s |
| SD05 | medium: After= and Wants= of network-online.target not paired; low: obsolete syslog.target / network.service | ordering | add the missing half |
| SD06 | medium if exposure >= 5, else low | sandboxing options missing (weighted exposure score) | the `--dropin` output, tested step by step |
| SD07 | high: CAP_SYS_ADMIN, CAP_SYS_MODULE, CAP_SYS_PTRACE, CAP_SYS_RAWIO; medium: CAP_DAC_OVERRIDE, CAP_NET_ADMIN, CAP_SETUID/SETGID; low: others | dangerous capabilities in AmbientCapabilities / CapabilityBoundingSet | keep only what is needed |
| SD08 | high: unknown Exec*/User*/Restart* directive or invalid Type/Restart value; medium: other unknown directive, invalid value, bad boolean, line without `=`, assignment before a section, unknown section | systemd logs a warning and ignores the line | fix the spelling (suggestion shown) |
| SD09 | medium | directive in the wrong section (StartLimitBurst in [Service], WantedBy in [Service], old StartLimitInterval) | move it |
| SD10 | high: pipes, redirects, `&&`, `;`, `&` or `$( )` in an Exec line without `/bin/sh -c`; medium: nohup / --daemon with Type=simple/exec; low: Type=forking without PIDFile | systemd does not use a shell | a script, `sh -c`, or foreground mode |
| SD11 | medium: no WantedBy/RequiredBy/Alias; low: WantedBy=default.target or graphical.target for a system service | enable at boot | WantedBy=multi-user.target |
| SD12 | medium: TimeoutStopSec=infinity/0, KillMode=none; low: KillMode=process | stop behaviour | TimeoutStopSec=30s, KillMode=mixed |
| SD13 | low | the same single-value key twice in one file | keep one |
| SD14 | high: ProtectHome=yes/tmpfs with an app, working dir or env file under /home or /root; MemoryDenyWriteExecute with node/java/dotnet/pypy/bun/deno; medium: ProtectSystem=strict with a write path not in ReadWritePaths/StateDirectory/LogsDirectory/RuntimeDirectory | options that break the service | move the app, add the write path, drop the option |
| SD15 | info | no MemoryMax/MemoryHigh/TasksMax/CPUQuota | add limits from observed usage |

## Exposure score
Weights (sum 10): run as non-root 2.0, ProtectSystem=strict 1.0 (full/yes counts half), NoNewPrivileges 0.8, ProtectHome 0.6, CapabilityBoundingSet without dangerous caps 0.6, PrivateTmp 0.5, PrivateDevices 0.5, ProtectKernelTunables 0.4, ProtectKernelModules 0.4, RestrictNamespaces 0.4, SystemCallFilter 0.4, RestrictAddressFamilies 0.4, ProtectControlGroups 0.3, RestrictSUIDSGID 0.3, MemoryDenyWriteExecute 0.3, ProtectKernelLogs 0.2, ProtectClock 0.2, RestrictRealtime 0.2, UMask 0077/0027 0.2, ProtectHostname 0.1, LockPersonality 0.1, SystemCallArchitectures=native 0.1.
Rating: under 2 OK, under 5 MEDIUM, under 8 EXPOSED, 8 or more UNSAFE.

## Verdict
- **N high finding(s) - fix before deploying**
- **works, but exposure X - add the hardening drop-in** (no high, exposure 5 or more)
- **looks good**
FILE:templates/unit-review.md
# systemd unit review: <unit name>

**Host:** <distro, systemd version>   **Runtime:** <Node.js 20 / Go binary / Python>   **Reviewed:** <YYYY-MM-DD>
**Verdict:** <9 high findings - fix before deploying>   **Exposure:** <8.1 before -> 1.5 after>

## Must fix
| # | Problem | Why it matters | Fix |
|---|---|---|---|
| 1 | <runs as root> | <an exploit owns the machine> | <DynamicUser=yes + CAP_NET_BIND_SERVICE for port 443> |
| 2 | <secret STRIPE_SECRET_KEY in Environment=> | <readable by every local user - rotate it> | <LoadCredential=stripe:/etc/shop-api/stripe> |

## Should fix
- <Restart/RestartSec, network-online pairing, [Install], stop timeout>

## Corrected unit
```ini
<full corrected .service file>
```

## Hardening drop-in (`systemctl edit <unit>`)
```ini
<drop-in from --dropin, adjusted for this service>
```

## Rollout
1. Rotate the leaked secrets; put the new ones in /etc/<name>/ (mode 0600).
2. `systemd-analyze verify /etc/systemd/system/<unit>`
3. `systemctl daemon-reload && systemctl restart <unit>`; check `journalctl -u <unit> -b` and the health endpoint.
4. `systemd-analyze security <unit>` - expect <exposure>.
5. Roll back: `systemctl revert <unit>` removes the drop-ins.

## Open questions
- [ ] <which directories does it write?>
- [ ] <does it need the network at start?>
FILE:examples/webapp.service
# /etc/systemd/system/shopapi.service - Node.js shop API, deployed by hand in 2023
[Unit]
Description=Shop API (Node.js)
After=network-online.target syslog.target
StartLimitBurst=3

[Service]
Type=simple
WorkingDirectory=/home/deploy/shop-api
Environment=NODE_ENV=production PORT=443
Environment="DATABASE_URL=postgres://shop:Winter2024@db.internal:5432/shop"
Environment=STRIPE_SECRET_KEY=sk_live_EXAMPLE_not_a_real_key
ExecStartPre=/usr/bin/npm run migrate
ExecStart=/usr/bin/node server.js >> /var/log/shop-api.log 2>&1
Restart=always
RestartSecs=5
AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_SYS_ADMIN
TimeoutStopSec=infinity
ProtectSystem=full
PrivateTmp=yes
FILE:examples/webapp-override.conf
# /etc/systemd/system/shopapi.service.d/override.conf - added during an incident
[Service]
ProtectHome=yes
MemoryDenyWriteExecute=yes
Restart=on-faliure
FILE:examples/worker.service
# /etc/systemd/system/thumbnailer.service - image thumbnail worker
[Unit]
Description=Thumbnail worker
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Type=exec
ExecStart=/usr/local/bin/thumbnailer \
    --queue redis://127.0.0.1:6379/2 \
    --out /var/lib/thumbnailer
DynamicUser=yes
StateDirectory=thumbnailer
LoadCredential=s3-key:/etc/thumbnailer/s3-key
Restart=on-failure
RestartSec=5s
TimeoutStopSec=30s
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
LockPersonality=yes
CapabilityBoundingSet=
SystemCallFilter=@system-service
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
MemoryMax=1G
TasksMax=128

[Install]
WantedBy=multi-user.target
FILE:examples/review-report.md
# systemd unit review

| Unit | Description | Type | Runs as | Restart | Exposure (0-10) | High | Medium | Low | Info |
|---|---|---|---|---|---|---|---|---|---|
| `examples/webapp.service + examples/webapp-override.conf` | Shop API (Node.js) | simple | root | always | 8.1 UNSAFE | 9 | 4 | 2 | 1 |
| `examples/worker.service` | Thumbnail worker | exec | DynamicUser | on-failure | 1.2 OK | 0 | 0 | 1 | 0 |

## examples/webapp.service + examples/webapp-override.conf

**Verdict: 9 high finding(s) - fix before deploying**

| Sev | Code | Where | Finding | Fix |
|---|---|---|---|---|
| high | SD01 | examples/webapp.service | runs as root (no User= or DynamicUser=) - a bug or exploit in the service owns the whole machine | add DynamicUser=yes, or create a system user (useradd --system --no-create-home <name>) and set User=/Group= |
| high | SD03 | examples/webapp.service:11 | DATABASE_URL contains a URL with an embedded password | move the credential out of the unit (LoadCredential= / EnvironmentFile= 0600) |
| high | SD03 | examples/webapp.service:12 | secret STRIPE_SECRET_KEY is written in the unit file - unit files are world-readable and shown by `systemctl show` | use LoadCredential=stripe_secret_key:/etc/<name>/stripe_secret_key (read from $CREDENTIALS_DIRECTORY) or EnvironmentFile=/etc/<name>/env with mode 0600 |
| high | SD07 | examples/webapp.service:17 | grants CAP_SYS_ADMIN - close to full root | remove it; to bind ports below 1024 only CAP_NET_BIND_SERVICE is needed |
| high | SD08 | examples/webapp-override.conf:5 | invalid value Restart=on-faliure - systemd ignores the line and keeps the default (did you mean on-failure?) | use one of: always, no, on-abnormal, on-abort, on-failure, on-success, on-watchdog |
| high | SD08 | examples/webapp.service:16 | unknown directive RestartSecs= (ignored by systemd) - did you mean RestartSec=? | rename to RestartSec= |
| high | SD10 | examples/webapp.service:14 | ExecStart= uses shell syntax (pipes, redirects, &&, ;, & or $( )) but systemd does not run a shell - the symbols are passed as plain arguments | wrap it: ExecStart=/bin/sh -c '...' or better move the logic into a script; log to stdout (journald) instead of >> file |
| high | SD14 | examples/webapp-override.conf:3 | ProtectHome=yes but the service uses /home/deploy/shop-api - it cannot see it and will fail to start | move the app out of /home (e.g. /opt/<name>, /srv/<name>) or use ProtectHome=read-only with ReadWritePaths= for the exact directory |
| high | SD14 | examples/webapp-override.conf:4 | MemoryDenyWriteExecute=yes with a JIT runtime (/usr/bin/node) - it will crash on start | remove MemoryDenyWriteExecute= for this service |
| medium | SD05 | examples/webapp.service:4 | After=network-online.target without Wants=network-online.target - the ordering does nothing unless something else pulls the target in | add Wants=network-online.target |
| medium | SD06 | examples/webapp.service | 19 sandboxing option(s) missing: User, NoNewPrivileges, ProtectSystem, PrivateDevices, ProtectKernelTunables, ProtectKernelModules, ProtectKernelLogs, ProtectControlGroups, ProtectClock, ProtectHostname, RestrictSUIDSGID, RestrictNamespaces, RestrictRealtime, LockPersonality, CapabilityBoundingSet, SystemCallFilter, SystemCallArchitectures, RestrictAddressFamilies, UMask | add the suggested drop-in (systemctl edit <name>), restart, and test the service |
| medium | SD11 | examples/webapp.service | no [Install] WantedBy= - `systemctl enable` does nothing, so the service will not start at boot | add [Install] WantedBy=multi-user.target |
| medium | SD12 | examples/webapp.service:18 | stop timeout infinity - a hung service blocks shutdown and restarts forever | set TimeoutStopSec=30s (or what a clean shutdown really needs) |
| low | SD04 | examples/webapp.service:15 | Restart=always without RestartSec= (default 100ms) - a crash loop hammers the machine and hits the start limit fast | add RestartSec=5s |
| low | SD05 | examples/webapp.service:4 | After=syslog.target is obsolete | remove it (journald is always available; use network-online.target) |
| info | SD15 | examples/webapp.service | no resource limits (MemoryMax=, TasksMax=, CPUQuota=) - a leak can starve the host | add MemoryMax= and TasksMax= sized from normal usage |

Suggested drop-in (test after applying; relax an option if the service needs it):

```ini
# /etc/systemd/system/<name>.service.d/hardening.conf  (systemctl edit <name>)
[Service]
# or User=<service account>; a non-root user needs AmbientCapabilities=CAP_NET_BIND_SERVICE for ports below 1024
DynamicUser=yes
NoNewPrivileges=yes
# currently full; add ReadWritePaths=/StateDirectory= for paths it writes
ProtectSystem=strict
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictRealtime=yes
LockPersonality=yes
# empty = no capabilities; or list only what you need, e.g. CAP_NET_BIND_SERVICE
CapabilityBoundingSet=
SystemCallFilter=@system-service
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
UMask=0077
# then: systemctl daemon-reload && systemctl restart <name> && systemd-analyze security <name>
```

## examples/worker.service

**Verdict: looks good (exposure 1.2)**

| Sev | Code | Where | Finding | Fix |
|---|---|---|---|---|
| low | SD06 | examples/worker.service | 6 sandboxing option(s) missing: ProtectKernelLogs, ProtectClock, ProtectHostname, RestrictRealtime, MemoryDenyWriteExecute, UMask | add the suggested drop-in (systemctl edit <name>), restart, and test the service |

Suggested drop-in (test after applying; relax an option if the service needs it):

```ini
# /etc/systemd/system/<name>.service.d/hardening.conf  (systemctl edit <name>)
[Service]
ProtectKernelLogs=yes
ProtectClock=yes
ProtectHostname=yes
RestrictRealtime=yes
# not for JIT runtimes (Node.js, Java, .NET, PyPy)
MemoryDenyWriteExecute=yes
UMask=0077
# then: systemctl daemon-reload && systemctl restart <name> && systemd-analyze security <name>
```

Exposure is this script's weighted estimate, not the systemd-analyze number; run `systemd-analyze security <unit>` and `systemd-analyze verify <file>` on the target host to confirm.
FILE:scripts/systemd_unit_review.py
#!/usr/bin/env python3
"""systemd service unit hardening reviewer (stdlib only, Python 3.8+).

Reads one or more systemd .service unit files (and drop-in .conf files, applied in the order
given) the way systemd does - sections, comments, backslash line continuation, repeated keys,
empty assignments that reset a list - and reviews them for reliability and security problems:
running as root, secrets in Environment=, shell syntax in Exec lines without a shell, unknown or
misspelled directives and values, directives in the wrong section, restart and timeout policy,
network-online ordering, missing [Install], sandboxing gaps, dangerous capabilities and options
that will stop the service from starting. Prints an exposure score (0 = locked down, 10 = wide
open, in the spirit of `systemd-analyze security`) and a suggested hardening drop-in.

Usage:
  systemd_unit_review.py UNIT.service [DROPIN.conf ...] [--json] [--dropin]
  systemd_unit_review.py --self-test
Exit: 0 = no high findings, 1 = at least one high finding, 2 = bad input.
"""
import argparse, difflib, json, re, sys

SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}

UNIT_KEYS = {"Description", "Documentation", "Requires", "Requisite", "Wants", "BindsTo", "PartOf",
             "Upholds", "Conflicts", "Before", "After", "OnFailure", "OnSuccess", "PropagatesReloadTo",
             "ReloadPropagatedFrom", "JoinsNamespaceOf", "RequiresMountsFor", "OnFailureJobMode",
             "IgnoreOnIsolate", "StopWhenUnneeded", "RefuseManualStart", "RefuseManualStop",
             "AllowIsolate", "DefaultDependencies", "CollectMode", "FailureAction", "SuccessAction",
             "JobTimeoutSec", "JobRunningTimeoutSec", "StartLimitIntervalSec", "StartLimitBurst",
             "StartLimitAction", "RebootArgument", "SourcePath", "ConditionPathExists",
             "ConditionPathIsDirectory", "ConditionFileNotEmpty", "ConditionHost", "ConditionVirtualization",
             "ConditionKernelCommandLine", "ConditionACPower", "ConditionUser", "ConditionGroup",
             "AssertPathExists", "AssertFileNotEmpty", "ConditionEnvironment", "ConditionSecurity"}
SERVICE_KEYS = {"Type", "ExitType", "RemainAfterExit", "GuessMainPID", "PIDFile", "BusName",
                "ExecStart", "ExecStartPre", "ExecStartPost", "ExecCondition", "ExecReload", "ExecStop",
                "ExecStopPost", "RestartSec", "RestartSteps", "RestartMaxDelaySec", "TimeoutStartSec",
                "TimeoutStopSec", "TimeoutAbortSec", "TimeoutSec", "TimeoutStartFailureMode",
                "TimeoutStopFailureMode", "RuntimeMaxSec", "WatchdogSec", "Restart", "RestartMode",
                "SuccessExitStatus", "RestartPreventExitStatus", "RestartForceExitStatus", "RootDirectoryStartOnly",
                "NonBlocking", "NotifyAccess", "Sockets", "FileDescriptorStoreMax", "USBFunctionDescriptors",
                "OOMPolicy", "OpenFile", "ReloadSignal",
                # exec
                "WorkingDirectory", "RootDirectory", "RootImage", "User", "Group", "DynamicUser",
                "SupplementaryGroups", "PAMName", "UMask", "Environment", "EnvironmentFile", "PassEnvironment",
                "UnsetEnvironment", "StandardInput", "StandardOutput", "StandardError", "SyslogIdentifier",
                "LogLevelMax", "LogRateLimitIntervalSec", "LogRateLimitBurst", "Nice", "OOMScoreAdjust",
                "CPUSchedulingPolicy", "IOSchedulingClass", "LimitNOFILE", "LimitNPROC", "LimitCORE",
                "LimitMEMLOCK", "LimitAS", "LimitFSIZE", "CapabilityBoundingSet", "AmbientCapabilities",
                "NoNewPrivileges", "SecureBits", "ProtectSystem", "ProtectHome", "RuntimeDirectory",
                "StateDirectory", "CacheDirectory", "LogsDirectory", "ConfigurationDirectory",
                "RuntimeDirectoryMode", "StateDirectoryMode", "RuntimeDirectoryPreserve", "ReadWritePaths",
                "ReadOnlyPaths", "InaccessiblePaths", "ExecPaths", "NoExecPaths", "TemporaryFileSystem",
                "BindPaths", "BindReadOnlyPaths", "PrivateTmp", "PrivateDevices", "PrivateNetwork",
                "PrivateUsers", "PrivateIPC", "PrivateMounts", "ProtectHostname", "ProtectClock",
                "ProtectKernelTunables", "ProtectKernelModules", "ProtectKernelLogs", "ProtectControlGroups",
                "ProtectProc", "ProcSubset", "RestrictAddressFamilies", "RestrictNamespaces",
                "LockPersonality", "MemoryDenyWriteExecute", "RestrictRealtime", "RestrictSUIDSGID",
                "RemoveIPC", "SystemCallFilter", "SystemCallArchitectures", "SystemCallErrorNumber",
                "DevicePolicy", "DeviceAllow", "IPAddressAllow", "IPAddressDeny", "LoadCredential",
                "LoadCredentialEncrypted", "SetCredential", "SetCredentialEncrypted", "KeyringMode",
                "TTYPath", "UtmpIdentifier", "SELinuxContext", "AppArmorProfile", "KillMode", "KillSignal",
                "SendSIGKILL", "SendSIGHUP", "FinalKillSignal", "RestartKillSignal",
                # resource control
                "CPUQuota", "CPUWeight", "MemoryMax", "MemoryHigh", "MemoryLow", "MemoryMin", "MemorySwapMax",
                "TasksMax", "IOWeight", "Slice", "Delegate", "CPUAccounting", "MemoryAccounting",
                "IOAccounting", "TasksAccounting", "IPAccounting", "ManagedOOMSwap",
                "ManagedOOMMemoryPressure"}
INSTALL_KEYS = {"Alias", "WantedBy", "RequiredBy", "UpheldBy", "Also", "DefaultInstance"}
SECTIONS = {"Unit": UNIT_KEYS, "Service": SERVICE_KEYS, "Install": INSTALL_KEYS}
LIST_KEYS = {"Requires", "Wants", "After", "Before", "BindsTo", "PartOf", "Conflicts", "Documentation",
             "Environment", "EnvironmentFile", "ExecStartPre", "ExecStartPost", "ExecStop", "ExecStopPost",
             "ExecReload", "ExecCondition", "ReadWritePaths", "ReadOnlyPaths", "InaccessiblePaths",
             "SupplementaryGroups", "CapabilityBoundingSet", "AmbientCapabilities", "SystemCallFilter",
             "RestrictAddressFamilies", "DeviceAllow", "LoadCredential", "SetCredential", "WantedBy",
             "RequiredBy", "Alias", "Also", "BindPaths", "BindReadOnlyPaths", "IPAddressAllow",
             "IPAddressDeny", "OnFailure", "SuccessExitStatus", "RestartPreventExitStatus",
             "TemporaryFileSystem", "PassEnvironment", "UnsetEnvironment", "RequiresMountsFor",
             "StateDirectory", "RuntimeDirectory", "CacheDirectory", "LogsDirectory", "ExecStart",
             "ConditionPathExists", "AssertPathExists"}
VALUES = {
    "Type": {"simple", "exec", "forking", "oneshot", "dbus", "notify", "notify-reload", "idle"},
    "Restart": {"no", "on-success", "on-failure", "on-abnormal", "on-watchdog", "on-abort", "always"},
    "ProtectSystem": {"yes", "no", "true", "false", "full", "strict", "1", "0", "on", "off"},
    "ProtectHome": {"yes", "no", "true", "false", "read-only", "tmpfs", "1", "0", "on", "off"},
    "KillMode": {"control-group", "mixed", "process", "none"},
    "StandardOutput": None, "NotifyAccess": {"none", "main", "exec", "all"},
}
BOOL = {"yes", "no", "true", "false", "1", "0", "on", "off"}
BOOL_KEYS = {"NoNewPrivileges", "PrivateTmp", "PrivateDevices", "PrivateNetwork", "PrivateUsers",
             "ProtectKernelTunables", "ProtectKernelModules", "ProtectKernelLogs", "ProtectControlGroups",
             "ProtectHostname", "ProtectClock", "LockPersonality", "MemoryDenyWriteExecute",
             "RestrictRealtime", "RestrictSUIDSGID", "RemoveIPC", "DynamicUser", "RemainAfterExit",
             "PrivateIPC", "PrivateMounts", "DefaultDependencies"}
TRUE = {"yes", "true", "1", "on"}
SECRET_RE = re.compile(r"(PASS(WORD|WD)?|SECRET|TOKEN|API_?KEY|PRIVATE_?KEY|CREDENTIALS?|DSN)", re.I)
SHELL_RE = re.compile(r"(\|\||&&|(?<![\\])[|;]|>>?|<|\$\(|`|(?:^|\s)&\s*$|\s&$)")
DANGER_CAPS = {"CAP_SYS_ADMIN": "high", "CAP_SYS_MODULE": "high", "CAP_SYS_PTRACE": "high",
               "CAP_DAC_OVERRIDE": "medium", "CAP_SYS_RAWIO": "high", "CAP_NET_ADMIN": "medium",
               "CAP_SETUID": "medium", "CAP_SETGID": "medium", "CAP_CHOWN": "low", "CAP_SYS_CHROOT": "low"}
# (directive, wanted value test, weight, recommended line) - weights sum to 10.0
HARDEN = [
    ("User", None, 2.0, "DynamicUser=yes|or User=<service account>; a non-root user needs AmbientCapabilities=CAP_NET_BIND_SERVICE for ports below 1024"),
    ("NoNewPrivileges", "bool", 0.8, "NoNewPrivileges=yes"),
    ("ProtectSystem", "strict", 1.0, "ProtectSystem=strict"),
    ("ProtectHome", "home", 0.6, "ProtectHome=yes"),
    ("PrivateTmp", "bool", 0.5, "PrivateTmp=yes"),
    ("PrivateDevices", "bool", 0.5, "PrivateDevices=yes"),
    ("ProtectKernelTunables", "bool", 0.4, "ProtectKernelTunables=yes"),
    ("ProtectKernelModules", "bool", 0.4, "ProtectKernelModules=yes"),
    ("ProtectKernelLogs", "bool", 0.2, "ProtectKernelLogs=yes"),
    ("ProtectControlGroups", "bool", 0.3, "ProtectControlGroups=yes"),
    ("ProtectClock", "bool", 0.2, "ProtectClock=yes"),
    ("ProtectHostname", "bool", 0.1, "ProtectHostname=yes"),
    ("RestrictSUIDSGID", "bool", 0.3, "RestrictSUIDSGID=yes"),
    ("RestrictNamespaces", "ns", 0.4, "RestrictNamespaces=yes"),
    ("RestrictRealtime", "bool", 0.2, "RestrictRealtime=yes"),
    ("LockPersonality", "bool", 0.1, "LockPersonality=yes"),
    ("MemoryDenyWriteExecute", "bool", 0.3, "MemoryDenyWriteExecute=yes|not for JIT runtimes (Node.js, Java, .NET, PyPy)"),
    ("CapabilityBoundingSet", "caps", 0.6, "CapabilityBoundingSet=|empty = no capabilities; or list only what you need, e.g. CAP_NET_BIND_SERVICE"),
    ("SystemCallFilter", "set", 0.4, "SystemCallFilter=@system-service"),
    ("SystemCallArchitectures", "native", 0.1, "SystemCallArchitectures=native"),
    ("RestrictAddressFamilies", "set", 0.4, "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6"),
    ("UMask", "umask", 0.2, "UMask=0077"),
]


class InputError(Exception):
    pass


# ---------------------------------------------------------------- parse
def parse(text, fname, unit=None):
    """Return unit dict: {section: {key: [(value, line, file)]}} plus raw entries list."""
    unit = unit if unit is not None else {"sections": {}, "entries": [], "files": []}
    unit["files"].append(fname)
    sec = None
    lines = text.splitlines()
    i = 0
    while i < len(lines):
        ln, raw = i + 1, lines[i]
        i += 1
        s = raw.strip()
        if not s or s[0] in "#;":
            continue
        while s.endswith("\\") and i < len(lines):
            nxt = lines[i].strip()
            i += 1
            if nxt.startswith(("#", ";")):
                continue
            s = s[:-1].rstrip() + " " + nxt
        if s.startswith("["):
            if not s.endswith("]"):
                raise InputError("%s:%d: broken section header %r" % (fname, ln, s))
            sec = s[1:-1].strip()
            unit["sections"].setdefault(sec, {})
            unit["entries"].append(("section", sec, None, ln, fname))
            continue
        if "=" not in s:
            unit["entries"].append(("noeq", sec, s, ln, fname))
            continue
        if sec is None:
            unit["entries"].append(("nosec", None, s, ln, fname))
            continue
        k, v = s.split("=", 1)
        k, v = k.strip(), v.strip()
        unit["entries"].append(("kv", sec, (k, v), ln, fname))
        d = unit["sections"][sec]
        if v == "" and k in LIST_KEYS:
            d[k] = [("", ln, fname)]  # empty assignment resets the list
            continue
        d.setdefault(k, []).append((v, ln, fname))
    return unit


def get(unit, sec, key):
    vals = unit["sections"].get(sec, {}).get(key, [])
    vals = [v for v in vals if v[0] != ""] if key in LIST_KEYS else vals
    return vals


def valid(key, v):
    vl = v.lower()
    if key in VALUES and VALUES[key]:
        return vl in VALUES[key]
    if key in BOOL_KEYS:
        return vl in BOOL
    return True


def last(unit, sec, key, default=None):
    """Effective value: the last assignment systemd accepts (invalid values are ignored)."""
    for v in reversed(unit["sections"].get(sec, {}).get(key) or []):
        if v[0] == "" or valid(key, v[0]):
            return v[0]
    return default


def where(unit, sec, key):
    v = [x for x in unit["sections"].get(sec, {}).get(key) or [] if x[0] == "" or valid(key, x[0])]
    return "%s:%d" % (v[-1][2], v[-1][1]) if v else unit["files"][0]


def words(unit, sec, key):
    out = []
    for v, _, _ in get(unit, sec, key):
        out += v.split()
    return out


# ---------------------------------------------------------------- review
def review(unit):
    F = []
    add = lambda sev, code, loc, msg, fix: F.append((sev, code, loc, msg, fix))
    S = unit["sections"]
    svc = S.get("Service", {})
    f0 = unit["files"][0]
    if "Service" not in S:
        raise InputError("%s has no [Service] section - not a service unit" % f0)

    # SD08 / SD13 / structure
    for kind, sec, val, ln, fn in unit["entries"]:
        loc = "%s:%d" % (fn, ln)
        if kind == "noeq":
            add("medium", "SD08", loc, "line is not Key=Value and is ignored: %r" % val, "fix the line or comment it out with #")
        elif kind == "nosec":
            add("medium", "SD08", loc, "assignment before any [Section] is ignored: %r" % val, "move it under [Unit], [Service] or [Install]")
        elif kind == "section" and sec not in SECTIONS and not sec.startswith("X-"):
            add("medium", "SD08", loc, "unknown section [%s] - its settings are ignored" % sec, "use [Unit], [Service] or [Install] (custom sections must start with X-)")
        elif kind == "kv" and sec in SECTIONS:
            k, v = val
            if k.startswith("X-"):
                continue
            if k not in SECTIONS[sec]:
                other = [s for s in SECTIONS if k in SECTIONS[s]]
                if other:
                    sev = "medium"
                    add(sev, "SD09", loc, "%s= belongs in [%s], not [%s] - systemd ignores it here" % (k, other[0], sec),
                        "move %s=%s to [%s]" % (k, v, other[0]))
                else:
                    allk = set().union(*SECTIONS.values())
                    close = difflib.get_close_matches(k, sorted(allk), 1, 0.75)
                    add("high" if sec == "Service" and close and close[0].startswith(("Exec", "User", "Restart")) else "medium",
                        "SD08", loc, "unknown directive %s= (ignored by systemd)%s" % (k, " - did you mean %s=?" % close[0] if close else ""),
                        "rename to %s=" % close[0] if close else "remove it or check `man systemd.directives`")
                continue
            vl = v.lower()
            if k in VALUES and VALUES[k] and v and vl not in VALUES[k]:
                close = difflib.get_close_matches(vl, sorted(VALUES[k]), 1, 0.6)
                add("high" if k in ("Type", "Restart") else "medium", "SD08", loc,
                    "invalid value %s=%s - systemd ignores the line and keeps the default%s" % (k, v, " (did you mean %s?)" % close[0] if close else ""),
                    "use one of: %s" % ", ".join(sorted(VALUES[k])))
            elif k in BOOL_KEYS and v and vl not in BOOL:
                add("medium", "SD08", loc, "%s= expects a boolean, got %r (line ignored)" % (k, v), "use yes or no")
    seen = {}
    for kind, sec, val, ln, fn in unit["entries"]:
        if kind == "kv" and val[0] not in LIST_KEYS and sec in SECTIONS:
            key = (sec, val[0])
            if key in seen and seen[key][2] == fn:
                add("low", "SD13", "%s:%d" % (fn, ln), "%s= set again (first at line %d) - the last one wins: %s" % (val[0], seen[key][1], val[1]),
                    "keep one %s= line" % val[0])
            seen[key] = (val[1], ln, fn)

    typ = (last(unit, "Service", "Type", "simple") or "simple").lower()
    execs = get(unit, "Service", "ExecStart")
    # SD02 ExecStart
    if not execs:
        add("high", "SD02", f0, "no ExecStart= - the service cannot start", "add ExecStart=/absolute/path/to/binary args")
    if len(execs) > 1 and typ != "oneshot":
        add("high", "SD02", where(unit, "Service", "ExecStart"), "%d ExecStart= lines but Type=%s allows only one (only oneshot may have several)" % (len(execs), typ),
            "keep one ExecStart=, or reset with an empty ExecStart= first in a drop-in")
    for key in ("ExecStartPre", "ExecStart", "ExecStartPost", "ExecReload", "ExecStop", "ExecStopPost"):
        for v, ln, fn in get(unit, "Service", key):
            loc = "%s:%d" % (fn, ln)
            cmd = v.lstrip("-@:+!")
            first = cmd.split()[0] if cmd.split() else ""
            is_shell = re.match(r"^(/usr)?/bin/(ba|da|z)?sh$", first) is not None
            if first and not first.startswith("/") and not first.startswith("$"):
                add("low", "SD02", loc, "%s= command %r is not an absolute path (systemd searches only its fixed PATH)" % (key, first),
                    "use the full path, e.g. /usr/bin/%s" % first)
            if not is_shell and SHELL_RE.search(cmd.split(None, 1)[1] if len(cmd.split(None, 1)) > 1 else ""):
                add("high", "SD10", loc, "%s= uses shell syntax (pipes, redirects, &&, ;, & or $( )) but systemd does not run a shell - the symbols are passed as plain arguments" % key,
                    "wrap it: %s=/bin/sh -c '...' or better move the logic into a script; log to stdout (journald) instead of >> file" % key)
            if re.search(r"(^|\s)(nohup|daemon|--daemon(ize)?|-d)(\s|$)", cmd) and typ in ("simple", "exec"):
                add("medium", "SD10", loc, "%s= looks like it forks into the background (nohup/--daemon) while Type=%s expects the process to stay in the foreground" % (key, typ),
                    "run it in the foreground (drop --daemon / nohup) or use Type=forking with PIDFile=")
            if v.startswith("+") or v.startswith("!"):
                add("medium", "SD01", loc, "%s= prefix %s runs this command with full privileges, bypassing User= and the sandbox" % (key, v[0]), "drop the prefix unless this step really needs root")
    if typ == "forking" and not get(unit, "Service", "PIDFile"):
        add("low", "SD10", where(unit, "Service", "Type"), "Type=forking without PIDFile= - systemd has to guess the main process", "add PIDFile=/run/<name>/<name>.pid or run in the foreground with Type=exec")

    # SD01 user
    user = last(unit, "Service", "User")
    dyn = (last(unit, "Service", "DynamicUser", "no") or "no").lower() in TRUE
    root = (not dyn) and (user is None or user in ("root", "0"))
    if root:
        add("high" if typ != "oneshot" else "medium", "SD01", where(unit, "Service", "User") if user else f0,
            "runs as root (%s) - a bug or exploit in the service owns the whole machine" % ("User=%s" % user if user else "no User= or DynamicUser="),
            "add DynamicUser=yes, or create a system user (useradd --system --no-create-home <name>) and set User=/Group=")
    caps = words(unit, "Service", "AmbientCapabilities") + words(unit, "Service", "CapabilityBoundingSet")
    for c in sorted(set(x.lstrip("~") for x in caps)):
        if c.upper() in DANGER_CAPS and not any(x.startswith("~") for x in caps):
            add(DANGER_CAPS[c.upper()], "SD07", where(unit, "Service", "AmbientCapabilities" if c in words(unit, "Service", "AmbientCapabilities") else "CapabilityBoundingSet"),
                "grants %s - close to full root" % c if DANGER_CAPS[c.upper()] == "high" else "grants %s" % c,
                "remove it; to bind ports below 1024 only CAP_NET_BIND_SERVICE is needed")

    # SD03 secrets
    for v, ln, fn in get(unit, "Service", "Environment"):
        for m in re.finditer(r'"?([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|\S*)', v):
            name, val = m.group(1), m.group(2).strip('"')
            if SECRET_RE.search(name) and val and not val.startswith("%") and not val.startswith("$"):
                add("high", "SD03", "%s:%d" % (fn, ln), "secret %s is written in the unit file - unit files are world-readable and shown by `systemctl show`" % name,
                    "use LoadCredential=%s:/etc/<name>/%s (read from $CREDENTIALS_DIRECTORY) or EnvironmentFile=/etc/<name>/env with mode 0600" % (name.lower(), name.lower()))
            if re.search(r"://[^/\s:@]+:[^@\s]+@", val):
                add("high", "SD03", "%s:%d" % (fn, ln), "%s contains a URL with an embedded password" % name, "move the credential out of the unit (LoadCredential= / EnvironmentFile= 0600)")
    for v, ln, fn in get(unit, "Service", "ExecStart"):
        if re.search(r"--(password|passwd|token|secret|api-key)[= ]\S+", v, re.I):
            add("high", "SD03", "%s:%d" % (fn, ln), "a secret is passed on the command line - visible to every user in ps and /proc", "read it from a file or $CREDENTIALS_DIRECTORY instead")

    # SD04 restart policy
    restart = (last(unit, "Service", "Restart", "no") or "no").lower()
    if typ != "oneshot" and restart == "no":
        add("medium", "SD04", where(unit, "Service", "Restart") if last(unit, "Service", "Restart") else f0,
            "Restart=no (default) - a crash leaves the service down until someone notices", "add Restart=on-failure and RestartSec=5s")
    if restart in ("always", "on-failure", "on-abnormal") and not last(unit, "Service", "RestartSec"):
        add("low", "SD04", where(unit, "Service", "Restart"), "Restart=%s without RestartSec= (default 100ms) - a crash loop hammers the machine and hits the start limit fast" % restart, "add RestartSec=5s")
    if typ == "oneshot" and restart in ("always", "on-success"):
        add("high", "SD04", where(unit, "Service", "Restart"), "Type=oneshot does not allow Restart=%s - systemd refuses to load the unit" % restart, "use Restart=on-failure or run it from a .timer")
    tstop = (last(unit, "Service", "TimeoutStopSec") or last(unit, "Service", "TimeoutSec") or "").lower()
    if tstop in ("infinity", "0"):
        add("medium", "SD12", where(unit, "Service", "TimeoutStopSec") if last(unit, "Service", "TimeoutStopSec") else where(unit, "Service", "TimeoutSec"),
            "stop timeout %s - a hung service blocks shutdown and restarts forever" % tstop, "set TimeoutStopSec=30s (or what a clean shutdown really needs)")
    km = (last(unit, "Service", "KillMode", "") or "").lower()
    if km == "none":
        add("medium", "SD12", where(unit, "Service", "KillMode"), "KillMode=none is deprecated - leftover processes survive stop and restart", "use KillMode=mixed or the default control-group")
    elif km == "process":
        add("low", "SD12", where(unit, "Service", "KillMode"), "KillMode=process kills only the main process; children keep running after stop", "use KillMode=mixed unless the children must outlive the service")

    # SD05 ordering
    after = words(unit, "Unit", "After")
    wants = words(unit, "Unit", "Wants") + words(unit, "Unit", "Requires")
    if "network-online.target" in after and "network-online.target" not in wants:
        add("medium", "SD05", where(unit, "Unit", "After"), "After=network-online.target without Wants=network-online.target - the ordering does nothing unless something else pulls the target in",
            "add Wants=network-online.target")
    if "network-online.target" in wants and "network-online.target" not in after:
        add("medium", "SD05", where(unit, "Unit", "Wants"), "Wants=network-online.target without After=network-online.target - the service still starts before the network is up", "add After=network-online.target")
    for v in ("syslog.target", "network.service"):
        if v in after:
            add("low", "SD05", where(unit, "Unit", "After"), "After=%s is obsolete" % v, "remove it (journald is always available; use network-online.target)")
    sl = [k for k in ("StartLimitIntervalSec", "StartLimitBurst") if k in S.get("Unit", {})]
    for k, ln in (("StartLimitInterval", None),):
        if k in svc:
            add("medium", "SD09", where(unit, "Service", k), "StartLimitInterval= is the old name and in [Service] it is ignored", "use StartLimitIntervalSec= in [Unit]")
    if restart == "always" and not sl and "StartLimitIntervalSec" not in svc:
        add("info", "SD04", f0, "Restart=always with the default start limit (5 starts in 10 s) - after that the unit stays failed", "if you want endless retries set StartLimitIntervalSec=0 in [Unit] and RestartSec= to a sane delay")

    # SD11 install
    wb = words(unit, "Install", "WantedBy") + words(unit, "Install", "RequiredBy")
    if not wb and not get(unit, "Install", "Alias"):
        add("medium", "SD11", f0, "no [Install] WantedBy= - `systemctl enable` does nothing, so the service will not start at boot", "add [Install] WantedBy=multi-user.target")
    elif "default.target" in wb or "graphical.target" in wb:
        add("low", "SD11", where(unit, "Install", "WantedBy"), "WantedBy=%s for a system service" % [w for w in wb if w in ("default.target", "graphical.target")][0], "use WantedBy=multi-user.target (default.target is for user services)")

    # SD14 conflicts that break start
    ph = (last(unit, "Service", "ProtectHome", "no") or "no").lower()
    wd = last(unit, "Service", "WorkingDirectory", "") or ""
    paths = [wd] + [(e[0].lstrip("-@:+!").split() or [""])[0] for e in execs] + [x.lstrip("-") for x in words(unit, "Service", "EnvironmentFile")]
    if ph in TRUE or ph in ("tmpfs", "read-only"):
        for p in paths:
            if p.startswith("/home/") or p.startswith("/root/"):
                add("high" if ph != "read-only" else "medium", "SD14", where(unit, "Service", "ProtectHome"),
                    "ProtectHome=%s but the service uses %s - it %s" % (ph, p, "cannot see it and will fail to start" if ph != "read-only" else "cannot write there"),
                    "move the app out of /home (e.g. /opt/<name>, /srv/<name>) or use ProtectHome=read-only with ReadWritePaths= for the exact directory")
                break
    ps_ = (last(unit, "Service", "ProtectSystem", "no") or "no").lower()
    rw = words(unit, "Service", "ReadWritePaths") + ["/var/lib/" + x for x in words(unit, "Service", "StateDirectory")] + \
        ["/var/log/" + x for x in words(unit, "Service", "LogsDirectory")] + ["/run/" + x for x in words(unit, "Service", "RuntimeDirectory")]
    if ps_ == "strict":
        for v, ln, fn in execs + get(unit, "Service", "ExecStartPre"):
            for m in re.finditer(r">>?\s*(/\S+)|--(?:log|pid|data|out)[-a-z]*[= ](/\S+)", v):
                p = m.group(1) or m.group(2)
                if not any(p.startswith(x.lstrip("-")) for x in rw) and not p.startswith(("/tmp", "/dev/")):
                    add("medium", "SD14", "%s:%d" % (fn, ln), "ProtectSystem=strict makes %s read-only - writes will fail" % p,
                        "use StateDirectory=/LogsDirectory=/RuntimeDirectory= or ReadWritePaths=%s" % p.rsplit("/", 1)[0])
    if (last(unit, "Service", "MemoryDenyWriteExecute", "no") or "").lower() in TRUE and paths and re.search(r"(node|java|dotnet|pypy|bun|deno)", " ".join(paths)):
        add("high", "SD14", where(unit, "Service", "MemoryDenyWriteExecute"), "MemoryDenyWriteExecute=yes with a JIT runtime (%s) - it will crash on start" % [p for p in paths if re.search(r"(node|java|dotnet|pypy|bun|deno)", p)][0],
            "remove MemoryDenyWriteExecute= for this service")
    if dyn and user:
        add("info", "SD01", where(unit, "Service", "DynamicUser"), "DynamicUser=yes with User=%s - a transient user with that name is allocated" % user, "fine; do not rely on files owned by a static %s user" % user)

    # SD15 resources
    if not any(k in svc for k in ("MemoryMax", "MemoryHigh", "TasksMax", "CPUQuota")):
        add("info", "SD15", f0, "no resource limits (MemoryMax=, TasksMax=, CPUQuota=) - a leak can starve the host", "add MemoryMax= and TasksMax= sized from normal usage")

    # SD06 exposure
    exposure, missing = 0.0, []
    for key, test, w, rec in HARDEN:
        v = (last(unit, "Service", key) or "")
        vl = v.lower()
        if key == "User":
            ok = not root
        elif test == "bool":
            ok = vl in TRUE
        elif test == "strict":
            ok = vl == "strict"
            if vl in ("full", "yes", "true"):
                exposure += w * 0.5
                missing.append((key, "ProtectSystem=strict|currently %s; add ReadWritePaths=/StateDirectory= for paths it writes" % v))
                continue
        elif test == "home":
            ok = vl in TRUE or vl in ("read-only", "tmpfs")
        elif test == "ns":
            ok = vl in TRUE or (v != "" and vl not in ("no", "false", "0", "off"))
        elif test == "caps":
            ok = key in svc and all(c.upper() not in DANGER_CAPS or c.startswith("~") for c in words(unit, "Service", key))
        elif test == "set":
            ok = bool(words(unit, "Service", key))
        elif test == "native":
            ok = vl == "native"
        elif test == "umask":
            ok = v in ("0077", "077", "0027", "027")
        else:
            ok = bool(v)
        if not ok:
            exposure += w
            missing.append((key, rec))
    exposure = round(min(exposure, 10.0), 1)
    if missing:
        add("medium" if exposure >= 5 else "low", "SD06", f0, "%d sandboxing option(s) missing: %s" % (len(missing), ", ".join(k for k, _ in missing)),
            "add the suggested drop-in (systemctl edit <name>), restart, and test the service")
    F.sort(key=lambda x: (SEV[x[0]], x[1], x[2]))
    rating = "OK" if exposure < 2 else "MEDIUM" if exposure < 5 else "EXPOSED" if exposure < 8 else "UNSAFE"
    return {"files": unit["files"], "name": last(unit, "Unit", "Description", "") or unit["files"][0], "type": typ,
            "user": "DynamicUser" if dyn else (user or "root"), "restart": restart, "exposure": exposure, "rating": rating,
            "missing": missing, "findings": F}


def dropin(r):
    lines = ["# /etc/systemd/system/<name>.service.d/hardening.conf  (systemctl edit <name>)", "[Service]"]
    for k, rec in r["missing"]:
        line, _, note = rec.partition("|")
        if note:
            lines.append("# " + note)
        lines.append(line)
    if any(f[1] == "SD04" and "Restart=no" in f[3] for f in r["findings"]):
        lines += ["Restart=on-failure", "RestartSec=5s"]
    lines += ["# then: systemctl daemon-reload && systemctl restart <name> && systemd-analyze security <name>"]
    return "\n".join(lines)


def counts(F):
    c = {s: 0 for s in SEV}
    for f in F:
        c[f[0]] += 1
    return c


def report(results):
    out = ["# systemd unit review", ""]
    out.append("| Unit | Description | Type | Runs as | Restart | Exposure (0-10) | High | Medium | Low | Info |")
    out.append("|---|---|---|---|---|---|---|---|---|---|")
    for r in results:
        c = counts(r["findings"])
        out.append("| `%s` | %s | %s | %s | %s | %.1f %s | %d | %d | %d | %d |" % (" + ".join(r["files"]), r["name"], r["type"], r["user"], r["restart"],
                                                                         r["exposure"], r["rating"], c["high"], c["medium"], c["low"], c["info"]))
    for r in results:
        c = counts(r["findings"])
        out += ["", "## %s" % " + ".join(r["files"]), ""]
        if c["high"]:
            v = "%d high finding(s) - fix before deploying" % c["high"]
        elif r["exposure"] >= 5:
            v = "works, but exposure %.1f - add the hardening drop-in" % r["exposure"]
        else:
            v = "looks good (exposure %.1f)" % r["exposure"]
        out.append("**Verdict: %s**" % v)
        out += ["", "| Sev | Code | Where | Finding | Fix |", "|---|---|---|---|---|"]
        for s, code, loc, msg, fix in r["findings"]:
            out.append("| %s | %s | %s | %s | %s |" % (s, code, loc, msg.replace("|", "\\|"), fix.replace("|", "\\|")))
        if r["missing"]:
            out += ["", "Suggested drop-in (test after applying; relax an option if the service needs it):", "", "```ini", dropin(r), "```"]
    out += ["", "Exposure is this script's weighted estimate, not the systemd-analyze number; run `systemd-analyze security <unit>` and `systemd-analyze verify <file>` on the target host to confirm."]
    return "\n".join(out) + "\n"


def run_files(paths):
    unit = None
    for p in paths:
        try:
            with open(p, encoding="utf-8") as fh:
                txt = fh.read()
        except OSError as e:
            raise InputError("cannot read %s: %s" % (p, e))
        unit = parse(txt, p, unit)
    return review(unit)


# ---------------------------------------------------------------- self-test
def self_test():
    tests = []
    def t(name, cond):
        tests.append((name, bool(cond)))
    def R(txt, *drop):
        u = parse(txt, "a.service")
        for i, d in enumerate(drop):
            u = parse(d, "d%d.conf" % i, u)
        return review(u)
    def codes(txt, *drop):
        return [(f[0], f[1]) for f in R(txt, *drop)["findings"]]
    base = "[Unit]\nDescription=x\n[Service]\nExecStart=/usr/bin/app\n[Install]\nWantedBy=multi-user.target\n"
    good = ("[Unit]\nDescription=good\nWants=network-online.target\nAfter=network-online.target\n[Service]\nType=exec\nExecStart=/usr/bin/app\n"
            "DynamicUser=yes\nRestart=on-failure\nRestartSec=5s\nNoNewPrivileges=yes\nProtectSystem=strict\nProtectHome=yes\nPrivateTmp=yes\n"
            "PrivateDevices=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectKernelLogs=yes\nProtectControlGroups=yes\n"
            "ProtectClock=yes\nProtectHostname=yes\nRestrictSUIDSGID=yes\nRestrictNamespaces=yes\nRestrictRealtime=yes\nLockPersonality=yes\n"
            "MemoryDenyWriteExecute=yes\nCapabilityBoundingSet=\nSystemCallFilter=@system-service\nSystemCallArchitectures=native\n"
            "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6\nUMask=0077\nMemoryMax=512M\nTasksMax=64\n[Install]\nWantedBy=multi-user.target\n")
    g = R(good)
    t("fully hardened unit: exposure 0, no high/medium", g["exposure"] == 0 and not [f for f in g["findings"] if f[0] in ("high", "medium")])
    t("root by default -> SD01 high", ("high", "SD01") in codes(base))
    t("root oneshot -> SD01 medium", ("medium", "SD01") in codes(base.replace("[Service]\n", "[Service]\nType=oneshot\n")))
    t("User=svc clears SD01", "SD01" not in [c for _, c in codes(base.replace("[Service]\n", "[Service]\nUser=svc\n"))])
    t("missing ExecStart -> SD02 high", ("high", "SD02") in codes("[Service]\nUser=a\n[Install]\nWantedBy=multi-user.target\n"))
    t("relative command -> SD02 low", ("low", "SD02") in codes(base.replace("/usr/bin/app", "app")))
    t("two ExecStart with simple -> SD02 high", ("high", "SD02") in codes(base.replace("ExecStart=/usr/bin/app", "ExecStart=/a\nExecStart=/b")))
    t("empty ExecStart= in drop-in resets", ("high", "SD02") not in codes(base, "[Service]\nExecStart=\nExecStart=/usr/bin/other\n"))
    t("pipe in ExecStart -> SD10 high", ("high", "SD10") in codes(base.replace("/usr/bin/app", "/usr/bin/app | logger")))
    t("redirect >> -> SD10 high", ("high", "SD10") in codes(base.replace("/usr/bin/app", "/usr/bin/app >> /var/log/app.log")))
    t("sh -c is allowed", ("high", "SD10") not in codes(base.replace("/usr/bin/app", "/bin/sh -c '/usr/bin/app | logger'")))
    t("--daemon with simple -> SD10 medium", ("medium", "SD10") in codes(base.replace("/usr/bin/app", "/usr/bin/app --daemon")))
    t("secret in Environment -> SD03 high", ("high", "SD03") in codes(base.replace("[Service]\n", "[Service]\nEnvironment=DB_PASSWORD=hunter2\n")))
    t("quoted env with URL password -> SD03", ("high", "SD03") in codes(base.replace("[Service]\n", '[Service]\nEnvironment="DATABASE_URL=postgres://u:pw@db/x" "PORT=80"\n')))
    t("non-secret env is fine", "SD03" not in [c for _, c in codes(base.replace("[Service]\n", "[Service]\nEnvironment=PORT=8080 LOG_LEVEL=info\n"))])
    t("--password on cmdline -> SD03", ("high", "SD03") in codes(base.replace("/usr/bin/app", "/usr/bin/app --password=s3cret")))
    t("Restart missing -> SD04 medium", ("medium", "SD04") in codes(base))
    t("Restart=always no RestartSec -> SD04 low", ("low", "SD04") in codes(base.replace("[Service]\n", "[Service]\nRestart=always\n")))
    t("oneshot + Restart=always -> SD04 high", ("high", "SD04") in codes(base.replace("[Service]\n", "[Service]\nType=oneshot\nRestart=always\n")))
    t("After online without Wants -> SD05", ("medium", "SD05") in codes(base.replace("[Unit]\n", "[Unit]\nAfter=network-online.target\n")))
    t("Wants online without After -> SD05", ("medium", "SD05") in codes(base.replace("[Unit]\n", "[Unit]\nWants=network-online.target\n")))
    t("syslog.target obsolete -> SD05 low", ("low", "SD05") in codes(base.replace("[Unit]\n", "[Unit]\nAfter=syslog.target\n")))
    t("typo directive -> SD08 with suggestion", any("did you mean RestartSec" in f[3] for f in R(base.replace("[Service]\n", "[Service]\nRestartSecs=5\n"))["findings"]))
    t("invalid Restart value -> SD08 high", ("high", "SD08") in codes(base.replace("[Service]\n", "[Service]\nRestart=on-faliure\n")))
    t("invalid value ignored, earlier valid one stays effective", R(base.replace("[Service]\n", "[Service]\nRestart=always\n"), "[Service]\nRestart=on-faliure\n")["restart"] == "always")
    t("drop-in lines are valid key=value", all("=" in l or l.startswith(("#", "[")) for l in dropin(R(base)).splitlines()))
    t("bad boolean -> SD08 medium", ("medium", "SD08") in codes(base.replace("[Service]\n", "[Service]\nPrivateTmp=maybe\n")))
    t("StartLimitBurst in [Service] -> SD09", ("medium", "SD09") in codes(base.replace("[Service]\n", "[Service]\nStartLimitBurst=3\n")))
    t("WantedBy in [Service] -> SD09", ("medium", "SD09") in codes(base.replace("[Service]\n", "[Service]\nWantedBy=multi-user.target\n")))
    t("no [Install] -> SD11 medium", ("medium", "SD11") in codes("[Service]\nExecStart=/usr/bin/a\n"))
    t("WantedBy=default.target -> SD11 low", ("low", "SD11") in codes(base.replace("multi-user.target", "default.target")))
    t("duplicate scalar -> SD13", ("low", "SD13") in codes(base.replace("[Service]\n", "[Service]\nUser=a\nUser=b\n")))
    t("drop-in override is not a duplicate", ("low", "SD13") not in codes(base.replace("[Service]\n", "[Service]\nUser=a\n"), "[Service]\nUser=b\n"))
    t("drop-in value wins", R(base.replace("[Service]\n", "[Service]\nUser=a\n"), "[Service]\nUser=b\n")["user"] == "b")
    t("ProtectHome with /home app -> SD14 high", ("high", "SD14") in codes(base.replace("/usr/bin/app", "/home/deploy/app/run").replace("[Service]\n", "[Service]\nProtectHome=yes\n")))
    t("MDWE with node -> SD14 high", ("high", "SD14") in codes(base.replace("/usr/bin/app", "/usr/bin/node server.js").replace("[Service]\n", "[Service]\nMemoryDenyWriteExecute=yes\n")))
    t("ProtectSystem=strict write path -> SD14", ("medium", "SD14") in codes(base.replace("/usr/bin/app", "/usr/bin/app --log-file=/var/log/app/app.log").replace("[Service]\n", "[Service]\nProtectSystem=strict\n")))
    t("LogsDirectory clears strict write", ("medium", "SD14") not in codes(base.replace("/usr/bin/app", "/usr/bin/app --log-file=/var/log/app/app.log").replace("[Service]\n", "[Service]\nProtectSystem=strict\nLogsDirectory=app\n")))
    t("CAP_SYS_ADMIN -> SD07 high", ("high", "SD07") in codes(base.replace("[Service]\n", "[Service]\nAmbientCapabilities=CAP_SYS_ADMIN\n")))
    t("+ prefix -> SD01 medium", ("medium", "SD01") in codes(base.replace("[Service]\n", "[Service]\nUser=a\nExecStartPre=+/usr/bin/setup\n")))
    t("TimeoutStopSec=infinity -> SD12", ("medium", "SD12") in codes(base.replace("[Service]\n", "[Service]\nTimeoutStopSec=infinity\n")))
    t("continuation lines joined", R("[Service]\nExecStart=/usr/bin/app \\\n  --port 80\nUser=a\n[Install]\nWantedBy=multi-user.target\n")["findings"] is not None and
      parse("[Service]\nExecStart=/usr/bin/app \\\n  --port 80\n", "x")["sections"]["Service"]["ExecStart"][0][0] == "/usr/bin/app --port 80")
    t("bare exposure of minimal unit is 10", R(base)["exposure"] == 10.0)
    t("ProtectSystem=full counts half", abs(R(base.replace("[Service]\n", "[Service]\nProtectSystem=full\n"))["exposure"] - 9.5) < 0.01)
    try:
        R("[Unit]\nDescription=x\n")
        t("no [Service] rejected", False)
    except InputError:
        t("no [Service] rejected", True)
    t("report has verdict and table", "**Verdict:" in report([R(base)]) and "| Sev | Code |" in report([R(base)]))
    for n, ok in tests:
        print("%s  %s" % ("PASS" if ok else "FAIL", n))
    p = sum(ok for _, ok in tests)
    print("%d/%d passed" % (p, len(tests)))
    return 0 if p == len(tests) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Review systemd .service units for reliability and hardening.")
    ap.add_argument("units", nargs="*", help="UNIT.service, optionally followed by drop-in .conf files; use ',' between separate units")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--dropin", action="store_true", help="print only the suggested hardening drop-in(s)")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.units:
        ap.error("give at least one .service file")
    groups, cur = [], []
    for u in a.units:
        if u == ",":
            groups.append(cur); cur = []
        elif u.endswith(".service") and cur:
            groups.append(cur); cur = [u]
        else:
            cur.append(u)
    groups.append(cur)
    try:
        res = [run_files(g) for g in groups if g]
    except InputError as e:
        print("error: %s" % e, file=sys.stderr)
        return 2
    if a.json:
        print(json.dumps([{**r, "findings": [dict(zip(("severity", "code", "where", "finding", "fix"), f)) for f in r["findings"]]} for r in res], indent=2))
    elif a.dropin:
        print("\n\n".join(dropin(r) for r in res))
    else:
        sys.stdout.write(report(res))
    return 1 if any(f[0] == "high" for r in res for f in r["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonSecurityDevOps+2
F@f
0
过期特性开关清理审计
技能

审计陈旧 feature flag,给出可安全清理的清单与风险。

---
name: feature-flag-stale-cleanup-auditor
description: 审计陈旧 feature flag,给出可安全清理的清单与风险。
---

# 过期特性开关清理审计

你是「过期特性开关清理审计」技能助手。上方 description 为中文任务说明。下方保留原技能完整正文(含脚本与代码);执行时遵循中文说明与原文技术细节。

# Feature Flag Stale Cleanup Auditor

You help engineering teams pay down feature flag debt. Flags are cheap to add and nobody removes them: a release flag sits at 100% for months with the old code path still compiled in, a rollout stops at 25% and half the users get a different product, an A/B test runs for half a year, and a key misspelled in code quietly returns the SDK default so a "launched" feature is never shown. You compare the flag inventory with the code, decide for every flag whether to remove it (keeping the ON or the OFF path), finish it, decide it or archive it, and hand back a cleanup plan with the exact references to change.

You work from an exported or hand-written flag inventory and the source folders you are given. You do not change flags in the flag service: the team archives a flag only after the code change is deployed everywhere.

## Files in this skill

- `scripts/flag_audit.py` - reads a flags JSON (key, type release / experiment / ops / kill-switch / permission, owner, created, expires, archived, per-environment on / rollout / updated) and scans source folders (.ts .tsx .js .jsx .py .go .rb .java .kt .cs .php .swift .vue .svelte; skips node_modules, dist, vendor) for SDK calls, string literals and `flags.camelCase` properties; reports FF01-FF14 and a ranked cleanup plan with file:line references and effort (Python 3.8+ standard library only)
- `references/flag-lifecycle-and-removal.md` - flag types and their expected lifetime, how to remove a flag safely (keep ON / keep OFF), exporting inventories from common flag services, and policies that stop flag debt coming back
- `references/checks-and-codes.md` - every finding code, its severity and what to do, plus the input format
- `templates/cleanup-ticket.md` - the cleanup ticket / PR description for each flag
- `examples/flags.json` - nine flags of a web shop (release, experiment, kill-switch, permission, archived)
- `examples/src/checkout.ts`, `examples/src/Header.tsx`, `examples/src/search.py` - code using them, including a misspelled key, a key that does not exist and a key built at runtime
- `examples/cleanup-report.md` - the real scanner output on the example

## Workflow

### 1. Collect the inventory and the code
Ask which flag service they use and for an export of all flags with type, owner, creation date, expiry or ticket, and per-environment state (on/off, rollout percentage, last change). Map it into a JSON like `examples/flags.json` (see the reference for LaunchDarkly, Unleash, Flagsmith, GrowthBook and ConfigCat field names). Ask for the repositories or folders that read flags and the name of their flag-check function if it is not a common SDK call.

### 2. Run the scanner
```
python3 scripts/flag_audit.py examples/flags.json examples/src
python3 scripts/flag_audit.py flags.json web/src api/app --today 2026-11-05 --stale-days 21
python3 scripts/flag_audit.py flags.json src --call featureOn --call can
python3 scripts/flag_audit.py flags.json src --json
python3 scripts/flag_audit.py --self-test
```
Exit 0 = no high findings, 1 = high findings, 2 = bad input. Without a shell, search the code for each key (`git grep -n "<key>"`) and apply the rules in `references/checks-and-codes.md` by hand.

### 3. Read the result
- High first: FF01 keys in code that the flag service does not know (typos read the default, so the feature is effectively off) and FF14 archived flags still in code.
- Medium: FF02 fully on and FF03 fully off for longer than the stale window, FF12 stuck rollouts, FF09 long experiments, FF05 expired, FF08 production ahead of staging.
- Low/info: unreferenced flags (check other repos first), missing owner or expiry, long-lived flags (kill switches, permissions) listed but not judged stale, keys built at runtime that search cannot find.

### 4. Plan the cleanup
Work down the plan table: fix typos first, then remove flags with few references (effort S) to build momentum. For "keep ON" delete the check and the OFF branch and any code only it used; for "keep OFF" delete the guarded code. Stuck rollouts and experiments need a decision from the owner before code changes. Fill `templates/cleanup-ticket.md` per flag.

### 5. Deliver
Return the verdict, the ranked plan with owners and file:line lists, the tickets, and two or three policy suggestions (expiry dates on every temporary flag, a monthly cleanup slot, a lint rule or CI check that fails on unknown keys).

## Example result
Three high findings: `new-checkout-flow-v3` is read in checkout.ts but does not exist, `recs_carousel` in search.py is a typo of `recs-carousel` - so the carousel that is "100% on" for 170 days has never been shown - and the archived `legacy-pdf-invoices` is still checked. `new-checkout-flow` has been 100% on for 86 days and expired in September (2 references, keep ON), the 2025 holiday banner has been off for 303 days (keep OFF), `search-v2` is stuck at 25% for 63 days, and the pricing experiment has run 199 days. `dark-mode` is on in production but off in staging. Kill switch and permission flags are kept.

## Rules

- Never recommend removing a kill switch, ops or permission flag because it is old; they are long-lived by design.
- A flag with no references in the scanned folders may be used elsewhere (mobile apps, other services, config); say so before suggesting to archive it.
- Remove code first, deploy, then archive the flag in the service - never the other way round.
- A stuck rollout or a running experiment needs the owner's decision; do not pick the winner for them.
- Treat `today` and the stale window as inputs; say which values you used.
FILE:references/flag-lifecycle-and-removal.md
# Flag lifecycle and safe removal

## Flag types and expected lifetime
| Type | Purpose | Expected life | Done when |
|---|---|---|---|
| release | hide unfinished work, roll out gradually | days to a few weeks after 100% | 100% everywhere and stable for the stale window |
| experiment | A/B or multivariate test | the test duration (2-6 weeks typical) | a decision is made |
| ops | operational switch (cache on/off, batch size) | long | rarely; review yearly |
| kill-switch | turn off a risky dependency fast | permanent | never by age |
| permission | entitlement, beta access, plan feature | permanent (or move to the billing/entitlement system) | product decision |

## Removing a flag safely
1. Confirm the state with the owner: fully on (keep ON) or fully off (keep OFF), in every environment and every service that reads it.
2. Find all references (the scanner's list plus `git grep -n "<key>"` in other repos, mobile apps, infrastructure config, analytics dashboards and tests).
3. Keep ON: replace the check with the ON branch, delete the OFF branch, then delete code only the OFF branch used (old components, endpoints, styles, migrations guards). Keep OFF: delete the guarded code and anything only it used.
4. Delete flag-specific tests and fixtures; keep tests of the behaviour that stays.
5. Deploy to every environment. Mobile apps: wait until old app versions that read the flag are below your support threshold.
6. Then archive the flag in the flag service (archive, do not delete, so the history stays).
7. Close the ticket with the PR links.

## Common traps
- Typos and renamed keys: the SDK returns the default for unknown keys without an error, so code reading `recs_carousel` never sees `recs-carousel`. A CI check that compares literal keys with the inventory catches this.
- Archiving first: an archived flag returns the default everywhere still reading it - often the OFF path, i.e. an instant rollback of a launched feature.
- Stuck rollouts: 25% for two months means two products to support and test; finish or roll back.
- Experiments without an end date keep skewing metrics and analytics events.
- Keys built at runtime (`"export-format-" + region`) cannot be found by search; keep a constants file listing every possible key.

## Exporting the inventory
- LaunchDarkly: REST `GET /api/v2/flags/{projectKey}?summary=0` -> `key`, `creationDate` (ms), `temporary`, `archived`, `maintainerId`/`_maintainer`, `tags`, `environments.<env>.on`, `.lastModified`, rollout weights in `fallthrough.rollout.variations` (weight/1000 = percent). Map `temporary: false` to `permanent: true`.
- Unleash: `GET /api/admin/projects/<p>/features` -> `name`, `type` (release, experiment, operational, kill-switch, permission), `createdAt`, `stale`, `environments[].enabled`, gradual rollout strategy `parameters.rollout`.
- Flagsmith: `/api/v1/projects/<id>/features/` + per-environment `featurestates` (`enabled`, `updated_at`), percentage via segment overrides.
- GrowthBook: `/api/v1/features` -> `id`, `owner`, `dateCreated`, `archived`, `environments.<env>.enabled`, rollout rule `coverage` (0-1 -> percent).
- ConfigCat: Management API settings + values per environment; percentage options give the rollout.
- In-house config (YAML/JSON): one entry per flag; add created/owner/expires by hand from git blame.

## Policies that keep flag debt down
- Every temporary flag gets an owner, a ticket and an expiry date when it is created.
- Cleanup is part of the definition of done: the removal ticket is created with the flag.
- A monthly 30-minute flag review using this audit; a cap on active release flags per team.
- CI: fail on keys not in the inventory; warn on expired flags still referenced.
FILE:references/checks-and-codes.md
# Checks and finding codes

| Code | Severity | Check | What to do |
|---|---|---|---|
| FF01 | high | a flag-check call (isEnabled, variation, useFlag, is_enabled ...) uses a key that is not in the inventory; a close match is suggested (typo, `_` vs `-`) | fix the key or create the flag; until then the SDK returns the default value |
| FF02 | medium | release flag at 100% in the primary environment for `stale_days` or more | remove the flag, keep the ON path |
| FF03 | medium | release or experiment flag off / 0% in the primary environment for `stale_days` or more | remove the flag and the guarded code, or decide to launch |
| FF04 | low | no reference in the scanned files | check other repos and services; archive if unused |
| FF05 | medium | `expires` date passed (temporary flags) | remove or agree a new date |
| FF06 | info | ops, kill-switch, permission (or `permanent: true`) flag - not judged stale | review yearly |
| FF07 | low | no owner | assign one |
| FF08 | medium | primary environment ahead of staging/qa/test/dev, or no state for the primary environment | align environments |
| FF09 | medium | experiment older than `experiment_max_days` | decide, ship the winner, remove |
| FF10 | info | flag key built at runtime (variable, concatenation, template string) | use literal keys or a constants file |
| FF11 | low | temporary flag without `expires` | set an expiry |
| FF12 | medium | release rollout between 1 and 99% unchanged for `stale_days` | finish (100%) or roll back, then remove |
| FF13 | medium | key defined twice in the inventory | merge |
| FF14 | high | flag archived in the service but still referenced in code | remove the references |

## Verdict
- **N high finding(s)** when FF01/FF14 exist; else **N flag(s) ready to remove**; else **no stale flags**.

## Plan actions (in this order)
remove code (archived) -> remove, keep ON -> remove, keep OFF -> finish rollout -> decide experiment -> archive (no references). Within an action, flags with more references first. Effort: S 1-2 references, M 3-6, L 7+.

## How references are found
1. Calls: `<name>(` with a literal first argument (or a literal after one leading argument such as a client or user), for the built-in names plus `call_names` / `--call`.
2. Any string literal equal to a known key (constants files, config maps).
3. `flags.`, `features.`, `featureFlags.`, `toggles.` followed by the key or its camelCase form (`new-search` -> `flags.newSearch`).
Comments that contain the key in quotes are counted too - check the listed lines.

## Flags JSON
Top level: `today, primary_env (production), environments, stale_days (30), experiment_max_days (90), call_names[], flags[]`.
Flag: `key, type (release|experiment|ops|kill-switch|permission), owner, created, expires, permanent, archived, description, ticket, environments{name: {on, rollout 0-100, updated}}`. `rollout` defaults to 100 when on and 0 when off; `updated` is the last change of that environment (falls back to `created`).
FILE:templates/cleanup-ticket.md
# Remove feature flag `<key>`

**Owner:** <team-payments>   **Type:** <release>   **Ticket:** <PAY-412>   **Action:** <remove, keep ON>
**State:** <100% on in production since 2026-08-11 (86 days), 100% in staging>   **Expired:** <2026-09-30>

## Why
<Fully rolled out and stable for 86 days; the OFF path (legacy checkout) is dead code.>

## Change
- [ ] `<examples/src/checkout.ts:4>` - replace the check with the ON branch (`renderOnePageCheckout`)
- [ ] `<examples/src/checkout.ts:14>` - always use `oneClickButton()`
- [ ] delete code only the OFF path used: <renderLegacyCheckout, classicButton, legacy checkout styles>
- [ ] delete flag-specific tests and fixtures
- [ ] search other repos / mobile apps: `git grep -n "<key>"`

## Rollout
1. Merge and deploy to staging, then production.
2. Watch <checkout conversion, error rate> for <2 days>.
3. Archive `<key>` in <flag service> (archive, not delete).

## Risks / questions
- <Does the mobile app read this flag? Minimum supported version?>
- <Any dashboards or alerts keyed on the flag?>
FILE:examples/flags.json
{
  "today": "2026-11-05",
  "primary_env": "production",
  "stale_days": 30,
  "experiment_max_days": 90,
  "flags": [
    {"key": "new-checkout-flow", "type": "release", "owner": "team-payments", "created": "2026-06-02", "expires": "2026-09-30", "ticket": "PAY-412",
     "description": "New one-page checkout",
     "environments": {"production": {"on": true, "rollout": 100, "updated": "2026-08-11"}, "staging": {"on": true, "rollout": 100, "updated": "2026-07-20"}}},
    {"key": "search-v2", "type": "release", "owner": "team-search", "created": "2026-07-15", "expires": "2026-12-31",
     "description": "Typesense-backed search",
     "environments": {"production": {"on": true, "rollout": 25, "updated": "2026-09-03"}, "staging": {"on": true, "rollout": 100, "updated": "2026-08-01"}}},
    {"key": "holiday-banner-2025", "type": "release", "owner": "team-growth", "created": "2025-11-10", "expires": "2026-01-10",
     "description": "Christmas 2025 promo banner",
     "environments": {"production": {"on": false, "rollout": 0, "updated": "2026-01-06"}, "staging": {"on": false, "rollout": 0, "updated": "2026-01-06"}}},
    {"key": "pricing-page-test", "type": "experiment", "owner": "team-growth", "created": "2026-04-20", "expires": "2026-07-01",
     "description": "A/B test: annual plan first",
     "environments": {"production": {"on": true, "rollout": 50, "updated": "2026-04-22"}, "staging": {"on": true, "rollout": 50, "updated": "2026-04-22"}}},
    {"key": "dark-mode", "type": "release", "owner": "team-web", "created": "2026-10-01", "expires": "2026-12-15",
     "description": "Dark theme",
     "environments": {"production": {"on": true, "rollout": 100, "updated": "2026-10-28"}, "staging": {"on": false, "rollout": 0, "updated": "2026-10-02"}}},
    {"key": "payments-kill-switch", "type": "kill-switch", "owner": "team-payments", "created": "2025-03-01",
     "description": "Turn off card payments during a PSP outage",
     "environments": {"production": {"on": true, "rollout": 100, "updated": "2025-03-01"}, "staging": {"on": true, "rollout": 100, "updated": "2025-03-01"}}},
    {"key": "beta-reports-export", "type": "permission", "owner": "", "created": "2025-09-12",
     "description": "CSV export for beta customers",
     "environments": {"production": {"on": true, "rollout": 100, "updated": "2025-09-12"}}},
    {"key": "legacy-pdf-invoices", "type": "release", "owner": "team-billing", "created": "2025-05-01", "archived": true,
     "description": "Old PDF renderer",
     "environments": {"production": {"on": false, "rollout": 0, "updated": "2026-02-01"}}},
    {"key": "recs-carousel", "type": "release", "owner": "team-growth", "created": "2026-03-03",
     "description": "Recommendations carousel on product pages",
     "environments": {"production": {"on": true, "rollout": 100, "updated": "2026-05-19"}, "staging": {"on": true, "rollout": 100, "updated": "2026-05-01"}}}
  ]
}
FILE:examples/src/checkout.ts
import { flags } from "./flags";

export async function renderCheckout(user: User, cart: Cart) {
  if (await flags.isEnabled("new-checkout-flow", user)) {
    return renderOnePageCheckout(user, cart);
  }
  return renderLegacyCheckout(user, cart);
}

export async function payButton(user: User) {
  if (await flags.isEnabled("payments-kill-switch", user)) {
    return disabledButton("Card payments are paused. Please try again shortly.");
  }
  const oneClick = await flags.isEnabled("new-checkout-flow", user);
  return oneClick ? oneClickButton() : classicButton();
}

export async function invoiceLink(order: Order) {
  // TODO remove after PDF migration
  if (await flags.isEnabled("legacy-pdf-invoices")) {
    return legacyPdfUrl(order.id);
  }
  return invoiceServiceUrl(order.id);
}

export async function trackCheckoutVariant(user: User) {
  const variant = await flags.variation("new-checkout-flow-v3", user, "control");
  analytics.track("checkout_variant", { variant });
}
FILE:examples/src/Header.tsx
import { useFlag } from "@acme/flags-react";

export function Header() {
  const darkMode = useFlag("dark-mode");
  const showBanner = useFlag("holiday-banner-2025");
  return (
    <header className={darkMode ? "header header--dark" : "header"}>
      {showBanner && <PromoBanner text="Holiday sale: 20% off everything" />}
      <SearchBox />
    </header>
  );
}

export function PricingPage({ user }: { user: User }) {
  const annualFirst = useFlag("pricing-page-test");
  return annualFirst ? <PlansAnnualFirst /> : <PlansMonthlyFirst />;
}
FILE:examples/src/search.py
from app.flags import flags


def search(query, user):
    if flags.is_enabled("search-v2", user):
        return typesense_search(query)
    return postgres_search(query)


def export_reports(user, region):
    if not flags.is_enabled("beta-reports-export", user):
        raise PermissionError("export is in beta")
    # per-region experiment keys, e.g. "export-format-eu"
    if flags.is_enabled("export-format-" + region, user):
        return export_parquet(user)
    return export_csv(user)


def recommendations(product):
    if flags.is_enabled("recs_carousel"):
        return top_related(product, limit=12)
    return []
FILE:examples/cleanup-report.md
# Feature flag cleanup audit

Today 2026-11-05, primary environment `production`, stale after 30 days. 9 flag(s) in the inventory, 3 source file(s) scanned in examples/src.

**Verdict: 3 high finding(s) - flags used in code that the flag service does not know**

Cleanup candidates: 6 (4 removable now, 4 reference(s) to change). Findings: 3 high, 9 medium, 2 low, 3 info.

## Cleanup plan

| # | Flag | Type | Owner | Primary | Action | Why | Refs (files) | Effort | Where |
|---|---|---|---|---|---|---|---|---|---|
| 1 | `legacy-pdf-invoices` | release | team-billing | archived | **remove code** | archived but still in code | 1 (1) | S | `examples/src/checkout.ts:20` |
| 2 | `new-checkout-flow` | release | team-payments | 100% | **remove, keep ON** | 100% on for 86 days | 2 (1) | S | `examples/src/checkout.ts:4`, `examples/src/checkout.ts:14` |
| 3 | `recs-carousel` | release | team-growth | 100% | **remove, keep ON** | 100% on for 170 days | 0 (0) | - | - |
| 4 | `holiday-banner-2025` | release | team-growth | 0% | **remove, keep OFF** | off for 303 days | 1 (1) | S | `examples/src/Header.tsx:5` |
| 5 | `search-v2` | release | team-search | 25% | **finish rollout** | stuck at 25% | 1 (1) | S | `examples/src/search.py:5` |
| 6 | `pricing-page-test` | experiment | team-growth | 50% | **decide experiment** | running 199 days | 1 (1) | S | `examples/src/Header.tsx:15` |

Effort: S = 1-2 references, M = 3-6, L = 7 or more. "keep ON" = delete the flag check and the OFF branch; "keep OFF" = delete the guarded code. Archive the flag in the flag service only after the code change is deployed everywhere.

## Findings

| Sev | Code | Flag | Finding | Fix |
|---|---|---|---|---|
| high | FF01 | `new-checkout-flow-v3` | used in code (examples/src/checkout.ts:27) but not defined in the flag inventory - the SDK returns the default, so this code path is reading the wrong key | typo of new-checkout-flow? use the exact key |
| high | FF01 | `recs_carousel` | used in code (examples/src/search.py:20) but not defined in the flag inventory - the SDK returns the default, so this code path is reading the wrong key | typo of recs-carousel? use the exact key |
| high | FF14 | `legacy-pdf-invoices` | flag is archived in the flag service but still referenced in 1 place(s) - it now returns the default | remove the references |
| medium | FF02 | `new-checkout-flow` | release flag 100% on in production for 86 days | remove the flag and keep the ON path; delete the OFF branch and old code |
| medium | FF02 | `recs-carousel` | release flag 100% on in production for 170 days | remove the flag and keep the ON path; delete the OFF branch and old code |
| medium | FF03 | `holiday-banner-2025` | release flag off in production for 303 days - the guarded code is dead | remove the flag and the guarded code (or decide to launch it) |
| medium | FF05 | `holiday-banner-2025` | expired on 2026-01-10 (299 days ago) | remove it now or agree a new date with the owner |
| medium | FF05 | `new-checkout-flow` | expired on 2026-09-30 (36 days ago) | remove it now or agree a new date with the owner |
| medium | FF05 | `pricing-page-test` | expired on 2026-07-01 (127 days ago) | remove it now or agree a new date with the owner |
| medium | FF08 | `dark-mode` | production is at 100% but staging at 0% - production runs a path that staging does not test | align staging with production |
| medium | FF09 | `pricing-page-test` | experiment running for 199 days (limit 90) | call the result, ship the winner, remove the flag |
| medium | FF12 | `search-v2` | rollout stuck at 25% in production for 63 days - users get two versions | finish the rollout (100%) or roll back to 0, then remove |
| low | FF04 | `recs-carousel` | no references in 3 scanned file(s) | check other repos/services; if unused, archive it in the flag service |
| low | FF07 | `beta-reports-export` | no owner | assign an owner team or person |
| info | FF06 | `beta-reports-export` | permission flag - long-lived by design, not checked for staleness | review yearly; keep a description and owner |
| info | FF06 | `payments-kill-switch` | kill-switch flag - long-lived by design, not checked for staleness | review yearly; keep a description and owner |
| info | FF10 | `-` | flag key built at runtime at examples/src/search.py:14 - references to it cannot be found by search | prefer literal keys (or a constants file) so flags can be cleaned up |

## References per flag

| Flag | References |
|---|---|
| `beta-reports-export` | 1 |
| `dark-mode` | 1 |
| `holiday-banner-2025` | 1 |
| `legacy-pdf-invoices` | 1 |
| `new-checkout-flow` | 2 |
| `payments-kill-switch` | 1 |
| `pricing-page-test` | 1 |
| `recs-carousel` | 0 |
| `search-v2` | 1 |

Search covers string literals, common SDK calls and `flags.camelCase` properties in the scanned folders only; check other services, mobile apps and config repos before archiving a flag.
FILE:scripts/flag_audit.py
#!/usr/bin/env python3
"""Feature flag stale cleanup auditor (stdlib only, Python 3.8+).

Reads a feature flag inventory (JSON exported or written by hand from LaunchDarkly, Unleash,
Flagsmith, GrowthBook, ConfigCat or an in-house config) and scans a source tree for flag
references. Finds release flags that have been fully on (or off) for weeks and can be removed,
partial rollouts that got stuck, expired and orphaned flags, experiments that never ended,
flags used in code but missing from the flag service (they silently fall back to the default),
environment drift and flags with no owner - and writes a prioritised cleanup plan with the
exact file:line references to change.

Usage:
  flag_audit.py FLAGS.json SRC_DIR [SRC_DIR ...] [--today YYYY-MM-DD] [--stale-days N]
                [--call NAME ...] [--json]
  flag_audit.py --self-test
Exit: 0 = no high findings, 1 = at least one high finding, 2 = bad input.
"""
import argparse, datetime, difflib, json, os, re, sys

SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
TYPES = {"release", "experiment", "ops", "kill-switch", "permission"}
LONG_LIVED = {"ops", "kill-switch", "permission"}
EXTS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".rb", ".java", ".kt", ".cs", ".php", ".swift", ".vue", ".svelte"}
SKIP_DIRS = {"node_modules", ".git", "dist", "build", "vendor", "__pycache__", ".venv", "venv", "coverage"}
CALLS = ["isEnabled", "isFeatureEnabled", "variation", "boolVariation", "stringVariation", "useFlag", "useFeatureFlag",
         "useFeatureIsOn", "getFlag", "getFeatureValue", "isOn", "feature_enabled", "is_enabled", "flag_enabled", "enabled?",
         "Enabled", "IsEnabled", "getBooleanValue", "hasFeature"]
TOP_KEYS = {"today", "stale_days", "experiment_max_days", "primary_env", "environments", "flags", "call_names", "source"}
FLAG_KEYS = {"key", "type", "owner", "created", "expires", "permanent", "description", "environments", "archived", "ticket"}


class InputError(Exception):
    pass


def pdate(s, what):
    try:
        return datetime.date.fromisoformat(str(s).strip()[:10])
    except ValueError:
        raise InputError("%s must be YYYY-MM-DD, got %r" % (what, s))


def camel(k):
    parts = re.split(r"[-_.]", k)
    return parts[0] + "".join(p[:1].upper() + p[1:] for p in parts[1:])


# ---------------------------------------------------------------- load
def load_flags(cfg, today=None, stale_days=None):
    if not isinstance(cfg, dict) or not isinstance(cfg.get("flags"), list):
        raise InputError("flags file must be a JSON object with a \"flags\" list")
    bad = set(cfg) - TOP_KEYS
    if bad:
        raise InputError("unknown top-level key(s): %s" % ", ".join(sorted(bad)))
    c = {"today": pdate(today or cfg.get("today") or datetime.date.today().isoformat(), "today"),
         "stale": int(stale_days if stale_days is not None else cfg.get("stale_days", 30)),
         "exp_max": int(cfg.get("experiment_max_days", 90)),
         "primary": cfg.get("primary_env", "production"),
         "envs": cfg.get("environments") or [], "flags": {}, "dups": [], "calls": cfg.get("call_names") or []}
    for i, f in enumerate(cfg["flags"], 1):
        if not isinstance(f, dict) or not f.get("key"):
            raise InputError("flag #%d has no key" % i)
        badk = set(f) - FLAG_KEYS
        if badk:
            raise InputError("flag %s: unknown key(s) %s" % (f["key"], ", ".join(sorted(badk))))
        t = (f.get("type") or "release").lower()
        if t not in TYPES:
            raise InputError("flag %s: type must be one of %s, got %r" % (f["key"], ", ".join(sorted(TYPES)), t))
        envs = {}
        for e, st in (f.get("environments") or {}).items():
            if not isinstance(st, dict):
                raise InputError("flag %s env %s must be an object {on, rollout, updated}" % (f["key"], e))
            r = st.get("rollout", 100 if st.get("on") else 0)
            if not isinstance(r, (int, float)) or not 0 <= r <= 100:
                raise InputError("flag %s env %s: rollout must be 0-100, got %r" % (f["key"], e, r))
            envs[e] = {"on": bool(st.get("on")), "rollout": float(r),
                       "updated": pdate(st["updated"], "flag %s env %s updated" % (f["key"], e)) if st.get("updated") else None}
        fl = {"key": f["key"], "type": t, "owner": f.get("owner") or "", "ticket": f.get("ticket", ""),
              "created": pdate(f["created"], "flag %s created" % f["key"]) if f.get("created") else None,
              "expires": pdate(f["expires"], "flag %s expires" % f["key"]) if f.get("expires") else None,
              "permanent": bool(f.get("permanent")) or t in LONG_LIVED, "archived": bool(f.get("archived")),
              "description": f.get("description", ""), "envs": envs}
        if fl["key"] in c["flags"]:
            c["dups"].append(fl["key"])
        c["flags"][fl["key"]] = fl
    return c


def eff(st):
    """effective share of users getting the flag: 0-100"""
    return st["rollout"] if st["on"] else 0.0


# ---------------------------------------------------------------- scan
def scan(dirs, keys, calls):
    """Return refs {key: [(path, line, text)]}, unknown {literal: [(path,line)]}, dynamic [(path,line,text)], files"""
    calls = sorted(set(CALLS + list(calls)), key=len, reverse=True)
    alt = "|".join(re.escape(c) for c in calls)
    call_re = re.compile(r"(?:\b|\.)(%s)\s*\(\s*(?:\w+\s*,\s*)?([\"'`])([^\"'`$]+)\2\s*[,)]" % alt)
    dyn_re = re.compile(r"(?:\b|\.)(%s)\s*\(\s*(?![\"'`][^\"'`$]*[\"'`]\s*[,)])([^,)]+)[,)]" % alt)
    lit_re = re.compile(r"([\"'`])([A-Za-z0-9][A-Za-z0-9_.:-]{2,80})\1")
    camels = {camel(k): k for k in keys if camel(k) != k}
    prop_re = re.compile(r"\b(?:flags|features|featureFlags|toggles)\.([A-Za-z_]\w*)")
    refs, unknown, dynamic, nfiles = {k: [] for k in keys}, {}, [], 0
    for d in dirs:
        if not os.path.isdir(d):
            raise InputError("source directory not found: %s" % d)
        for root, ds, fs in os.walk(d):
            ds[:] = sorted(x for x in ds if x not in SKIP_DIRS)
            for fn in sorted(fs):
                if os.path.splitext(fn)[1] not in EXTS:
                    continue
                p = os.path.join(root, fn)
                nfiles += 1
                try:
                    lines = open(p, encoding="utf-8", errors="replace").read().splitlines()
                except OSError as e:
                    raise InputError("cannot read %s: %s" % (p, e))
                for i, ln in enumerate(lines, 1):
                    seen = set()
                    for m in call_re.finditer(ln):
                        lit = m.group(3)
                        if lit in refs:
                            refs[lit].append((p, i, ln.strip())); seen.add(lit)
                        else:
                            unknown.setdefault(lit, []).append((p, i, ln.strip()))
                            seen.add(lit)
                    for m in dyn_re.finditer(ln):
                        if m.group(2).strip() not in ("true", "false", "None", "null", ""):
                            dynamic.append((p, i, ln.strip()))
                    for m in lit_re.finditer(ln):
                        lit = m.group(2)
                        if lit in refs and lit not in seen:
                            refs[lit].append((p, i, ln.strip())); seen.add(lit)
                    for m in prop_re.finditer(ln):
                        k = camels.get(m.group(1)) or (m.group(1) if m.group(1) in refs else None)
                        if k and k not in seen:
                            refs[k].append((p, i, ln.strip())); seen.add(k)
    return refs, unknown, dynamic, nfiles


# ---------------------------------------------------------------- audit
def audit(c, refs, unknown, dynamic, nfiles):
    F, plan = [], []
    today, stale, prim = c["today"], c["stale"], c["primary"]
    add = lambda sev, code, key, msg, fix: F.append((sev, code, key, msg, fix))
    for k in c["dups"]:
        add("medium", "FF13", k, "flag defined twice in the inventory (the last one is used)", "merge the two entries")
    keys = list(c["flags"])
    norm = {re.sub(r"[-_.]", "", k.lower()): k for k in keys}
    for lit, where in sorted(unknown.items()):
        close = norm.get(re.sub(r"[-_.]", "", lit.lower())) or (difflib.get_close_matches(lit, keys, 1, 0.8) or [None])[0]
        locs = ", ".join("%s:%d" % (p, i) for p, i, _ in where[:3])
        add("high", "FF01", lit, "used in code (%s) but not defined in the flag inventory - the SDK returns the default, so this code path is %s" %
            (locs, "probably never on" if not close else "reading the wrong key"),
            ("typo of %s? use the exact key" % close) if close else "create the flag or remove the check")
    for p, i, txt in dynamic[:20]:
        add("info", "FF10", "-", "flag key built at runtime at %s:%d - references to it cannot be found by search" % (p, i), "prefer literal keys (or a constants file) so flags can be cleaned up")
    for k, f in c["flags"].items():
        r = refs.get(k, [])
        files = sorted(set(p for p, _, _ in r))
        st = f["envs"].get(prim)
        age = (today - f["created"]).days if f["created"] else None
        action, why = None, ""
        if not f["owner"]:
            add("low", "FF07", k, "no owner", "assign an owner team or person")
        if f["archived"]:
            if r:
                add("high", "FF14", k, "flag is archived in the flag service but still referenced in %d place(s) - it now returns the default" % len(r), "remove the references")
                plan.append({"key": k, "type": f["type"], "owner": f["owner"] or "?", "state": "archived", "action": "remove code",
                             "why": "archived but still in code", "refs": len(r), "files": len(files),
                             "locs": ["%s:%d" % (p, i) for p, i, _ in r], "ticket": f["ticket"]})
            continue
        if st is None and not f["archived"]:
            add("medium", "FF08", k, "no state for the primary environment %r" % prim, "add the %s state or fix primary_env" % prim)
        if f["permanent"]:
            add("info", "FF06", k, "%s flag - long-lived by design, not checked for staleness" % f["type"], "review yearly; keep a description and owner")
            if not r:
                add("low", "FF04", k, "no references in %d scanned file(s)" % nfiles, "check other repos/services; if unused, archive it in the flag service")
                action, why = action or "archive", "no references"
        elif st is not None:
            since = st["updated"] or f["created"]
            days = (today - since).days if since else None
            e = eff(st)
            all_on = all(eff(s) == 100 for s in f["envs"].values())
            if f["expires"] and f["expires"] < today:
                add("medium", "FF05", k, "expired on %s (%d days ago)" % (f["expires"], (today - f["expires"]).days), "remove it now or agree a new date with the owner")
            if f["type"] == "release" and e == 100 and days is not None and days >= stale:
                add("medium", "FF02", k, "release flag 100%% on in %s for %d days%s" % (prim, days, "" if all_on else " (not 100% in every environment)"),
                    "remove the flag and keep the ON path; delete the OFF branch and old code")
                action, why = "remove, keep ON", "100%% on for %d days" % days
            elif e == 0 and days is not None and days >= stale and f["type"] in ("release", "experiment"):
                add("medium", "FF03", k, "%s flag off in %s for %d days - the guarded code is dead" % (f["type"], prim, days),
                    "remove the flag and the guarded code (or decide to launch it)")
                action, why = "remove, keep OFF", "off for %d days" % days
            elif 0 < e < 100 and days is not None and days >= stale and f["type"] == "release":
                add("medium", "FF12", k, "rollout stuck at %g%% in %s for %d days - users get two versions" % (e, prim, days), "finish the rollout (100%) or roll back to 0, then remove")
                action, why = "finish rollout", "stuck at %g%%" % e
            if f["type"] == "experiment" and age is not None and age > c["exp_max"]:
                add("medium", "FF09", k, "experiment running for %d days (limit %d)" % (age, c["exp_max"]), "call the result, ship the winner, remove the flag")
                action, why = action or "decide experiment", "running %d days" % age
            if not r:
                add("low", "FF04", k, "no references in %d scanned file(s)" % nfiles, "check other repos/services; if unused, archive it in the flag service")
                action, why = action or "archive", why or "no references"
            for en, s2 in f["envs"].items():
                if en != prim and eff(s2) < e and (en in ("staging", "qa", "test", "dev", "development")):
                    add("medium", "FF08", k, "%s is at %g%% but %s at %g%% - production runs a path that staging does not test" % (prim, e, en, eff(s2)), "align %s with %s" % (en, prim))
            if f["expires"] is None and f["type"] in ("release", "experiment") and action is None:
                add("low", "FF11", k, "temporary %s flag without an expiry date" % f["type"], "set expires (e.g. 30-60 days after full rollout)")
        if action:
            plan.append({"key": k, "type": f["type"], "owner": f["owner"] or "?", "state": ("%g%%" % eff(st)) if st else "-", "action": action, "why": why,
                         "refs": len(r), "files": len(files), "locs": ["%s:%d" % (p, i) for p, i, _ in r], "ticket": f["ticket"]})
    rank = {"remove code": 0, "remove, keep ON": 1, "remove, keep OFF": 2, "finish rollout": 3, "decide experiment": 4, "archive": 5}
    plan.sort(key=lambda x: (rank[x["action"]], -x["refs"], x["key"]))
    F.sort(key=lambda x: (SEV[x[0]], x[1], x[2]))
    return {"today": today.isoformat(), "primary": prim, "stale_days": stale, "flags": len(c["flags"]), "files": nfiles,
            "refs": {k: len(v) for k, v in refs.items()}, "plan": plan, "findings": F}


def effort(n):
    return "S" if n <= 2 else "M" if n <= 6 else "L"


def report(r, src):
    c = {s: 0 for s in SEV}
    for f in r["findings"]:
        c[f[0]] += 1
    removable = [p for p in r["plan"] if p["action"].startswith("remove")]
    out = ["# Feature flag cleanup audit", "",
           "Today %s, primary environment `%s`, stale after %d days. %d flag(s) in the inventory, %d source file(s) scanned in %s." % (
               r["today"], r["primary"], r["stale_days"], r["flags"], r["files"], ", ".join(src)), ""]
    if c["high"]:
        v = "%d high finding(s) - flags used in code that the flag service does not know" % c["high"]
    elif removable:
        v = "%d flag(s) ready to remove" % len(removable)
    else:
        v = "no stale flags"
    out += ["**Verdict: %s**" % v, "", "Cleanup candidates: %d (%d removable now, %d reference(s) to change). Findings: %d high, %d medium, %d low, %d info." % (
        len(r["plan"]), len(removable), sum(p["refs"] for p in removable), c["high"], c["medium"], c["low"], c["info"]), ""]
    out += ["## Cleanup plan", "", "| # | Flag | Type | Owner | Primary | Action | Why | Refs (files) | Effort | Where |", "|---|---|---|---|---|---|---|---|---|---|"]
    for i, p in enumerate(r["plan"], 1):
        locs = ", ".join("`%s`" % x for x in p["locs"][:4]) + (" +%d more" % (len(p["locs"]) - 4) if len(p["locs"]) > 4 else "")
        out.append("| %d | `%s` | %s | %s | %s | **%s** | %s | %d (%d) | %s | %s |" % (i, p["key"], p["type"], p["owner"], p["state"], p["action"], p["why"],
                                                                                p["refs"], p["files"], effort(p["refs"]) if p["refs"] else "-", locs or "-"))
    out += ["", "Effort: S = 1-2 references, M = 3-6, L = 7 or more. \"keep ON\" = delete the flag check and the OFF branch; \"keep OFF\" = delete the guarded code. Archive the flag in the flag service only after the code change is deployed everywhere.", ""]
    out += ["## Findings", "", "| Sev | Code | Flag | Finding | Fix |", "|---|---|---|---|---|"]
    for s, code, k, msg, fix in r["findings"]:
        out.append("| %s | %s | `%s` | %s | %s |" % (s, code, k, msg.replace("|", "\\|"), fix.replace("|", "\\|")))
    out += ["", "## References per flag", "", "| Flag | References |", "|---|---|"]
    for k, n in sorted(r["refs"].items()):
        out.append("| `%s` | %d |" % (k, n))
    out += ["", "Search covers string literals, common SDK calls and `flags.camelCase` properties in the scanned folders only; check other services, mobile apps and config repos before archiving a flag."]
    return "\n".join(out) + "\n"


def run(flags_path, dirs, today=None, stale=None, calls=()):
    try:
        cfg = json.load(open(flags_path, encoding="utf-8"))
    except (OSError, ValueError) as e:
        raise InputError("cannot read %s: %s" % (flags_path, e))
    c = load_flags(cfg, today, stale)
    refs, unknown, dynamic, n = scan(dirs, list(c["flags"]), list(c["calls"]) + list(calls))
    return audit(c, refs, unknown, dynamic, n)


# ---------------------------------------------------------------- self-test
def self_test():
    import tempfile
    tests = []
    def t(name, cond):
        tests.append((name, bool(cond)))
    def mk(flags, files, **kw):
        d = tempfile.mkdtemp()
        for name, body in files.items():
            os.makedirs(os.path.dirname(os.path.join(d, name)) or d, exist_ok=True)
            open(os.path.join(d, name), "w").write(body)
        cfg = {"today": "2026-11-05", "flags": flags}
        cfg.update(kw)
        c = load_flags(cfg)
        refs, unk, dyn, n = scan([d], list(c["flags"]), c["calls"])
        return audit(c, refs, unk, dyn, n)
    def codes(r):
        return [(f[0], f[1], f[2]) for f in r["findings"]]
    P = lambda on, r, up: {"production": {"on": on, "rollout": r, "updated": up}}
    rel = lambda k, on=True, r=100, up="2026-09-01", **x: dict({"key": k, "type": "release", "owner": "web", "created": "2026-08-01", "expires": "2027-01-01", "environments": P(on, r, up)}, **x)
    src = {"a.ts": "if (client.isEnabled('new-checkout')) {}\nconst x = useFlag(\"dark-mode\")\n"}
    r = mk([rel("new-checkout"), rel("dark-mode", up="2026-10-30")], src)
    t("100% on for 65 days -> FF02", ("medium", "FF02", "new-checkout") in codes(r))
    t("100% on for 6 days -> not stale", ("medium", "FF02", "dark-mode") not in codes(r))
    t("plan action keep ON", [p["action"] for p in r["plan"] if p["key"] == "new-checkout"] == ["remove, keep ON"])
    t("refs found via call", r["refs"]["new-checkout"] == 1 and r["refs"]["dark-mode"] == 1)
    r = mk([rel("old-banner", on=False, r=0)], {"b.py": "if flags.is_enabled(\"old-banner\"):\n    pass\n"})
    t("off for 65 days -> FF03 keep OFF", ("medium", "FF03", "old-banner") in codes(r) and r["plan"][0]["action"] == "remove, keep OFF")
    r = mk([rel("half", r=50)], {"c.js": "isEnabled('half')"})
    t("stuck rollout -> FF12", ("medium", "FF12", "half") in codes(r))
    r = mk([rel("new-checkout")], {"a.ts": "isEnabled('new_checkout')\n"})
    t("undefined key in code -> FF01 high", ("high", "FF01", "new_checkout") in codes(r))
    t("FF01 suggests the close key", any("typo of new-checkout" in f[4] for f in r["findings"]))
    t("FF01 alone means 0 refs for the real key -> FF04", ("low", "FF04", "new-checkout") in codes(r))
    r = mk([rel("ghost")], {"a.ts": "const a = 1\n"})
    t("no refs -> FF04 + archive", ("low", "FF04", "ghost") in codes(r))
    r = mk([rel("exp1", type="experiment", created="2026-05-01", r=50, up="2026-10-20")], {"a.ts": "variation('exp1', false)"})
    t("experiment over 90 days -> FF09", ("medium", "FF09", "exp1") in codes(r))
    r = mk([rel("exp2", expires="2026-10-01", up="2026-10-30")], {"a.ts": "isEnabled('exp2')"})
    t("expired -> FF05", ("medium", "FF05", "exp2") in codes(r))
    r = mk([dict(rel("ks"), type="kill-switch", expires=None)], {"a.ts": "isEnabled('ks')"})
    t("kill-switch skipped -> FF06 info only", [x[1] for x in codes(r)] == ["FF06"])
    r = mk([dict(rel("p", up="2026-10-30"), owner="")], {"a.ts": "isEnabled('p')"})
    t("no owner -> FF07", ("low", "FF07", "p") in codes(r))
    r = mk([dict(rel("d", up="2026-10-30"), environments={"production": {"on": True, "rollout": 100, "updated": "2026-10-30"}, "staging": {"on": False}})], {"a.ts": "isEnabled('d')"})
    t("prod ahead of staging -> FF08", ("medium", "FF08", "d") in codes(r))
    r = mk([rel("x")], {"a.ts": "const k = 'x' + region\nisEnabled(flagName)\n"})
    t("dynamic key -> FF10 info", ("info", "FF10", "-") in codes(r))
    r = mk([rel("x")], {"a.py": "is_enabled('export-' + region)\n"})
    t("literal + variable is dynamic, not undefined", ("info", "FF10", "-") in codes(r) and not [f for f in r["findings"] if f[1] == "FF01"])
    r = mk([rel("new-search", up="2026-10-30")], {"s.tsx": "if (flags.newSearch) {}\n"})
    t("flags.camelCase property counted", r["refs"]["new-search"] == 1)
    r = mk([rel("new-search", up="2026-10-30")], {"node_modules/x.js": "isEnabled('new-search')", "a.md": "isEnabled('new-search')"})
    t("node_modules and non-code files skipped", r["refs"]["new-search"] == 0)
    r = mk([rel("t", up="2026-10-30", expires=None)], {"a.ts": "isEnabled('t')"})
    t("no expiry -> FF11", ("low", "FF11", "t") in codes(r))
    r = mk([rel("arch", archived=True)], {"a.ts": "isEnabled('arch')"})
    t("archived but referenced -> FF14 high", ("high", "FF14", "arch") in codes(r) and r["plan"][0]["action"] == "remove code")
    r = mk([rel("dup", up="2026-10-30"), rel("dup", up="2026-10-30")], {"a.ts": "isEnabled('dup')"})
    t("duplicate key -> FF13", ("medium", "FF13", "dup") in codes(r))
    r = mk([rel("cust", up="2026-10-30")], {"a.ts": "myFlags.check('cust')\nmyFlags.check('nope')\n"}, call_names=["check"])
    t("custom call name finds undefined keys", ("high", "FF01", "nope") in codes(r))
    r = mk([rel("stale14", up="2026-10-20")], {"a.ts": "isEnabled('stale14')"}, stale_days=14)
    t("stale_days setting respected", ("medium", "FF02", "stale14") in codes(r))
    for bad, name in (({"flags": [{"key": "a", "type": "temp"}]}, "bad type"), ({"flags": [{"key": "a", "environments": {"production": {"on": True, "rollout": 150}}}]}, "rollout > 100"),
                      ({"flags": [{"key": "a", "colour": 1}]}, "unknown flag key"), ({"flag": []}, "missing flags list")):
        try:
            load_flags(bad); t("rejected: " + name, False)
        except InputError:
            t("rejected: " + name, True)
    r = mk([rel("new-checkout")], src)
    t("report has verdict and plan", "**Verdict:" in report(r, ["x"]) and "## Cleanup plan" in report(r, ["x"]))
    for n, ok in tests:
        print("%s  %s" % ("PASS" if ok else "FAIL", n))
    p = sum(ok for _, ok in tests)
    print("%d/%d passed" % (p, len(tests)))
    return 0 if p == len(tests) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Find stale feature flags and plan their removal.")
    ap.add_argument("flags", nargs="?")
    ap.add_argument("src", nargs="*")
    ap.add_argument("--today")
    ap.add_argument("--stale-days", type=int)
    ap.add_argument("--call", action="append", default=[], help="extra flag-check function name (repeatable)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.flags or not a.src:
        ap.error("give FLAGS.json and at least one source directory")
    try:
        r = run(a.flags, a.src, a.today, a.stale_days, a.call)
    except InputError as e:
        print("error: %s" % e, file=sys.stderr)
        return 2
    if a.json:
        print(json.dumps({**r, "findings": [dict(zip(("severity", "code", "flag", "finding", "fix"), f)) for f in r["findings"]]}, indent=2))
    else:
        sys.stdout.write(report(r, a.src))
    return 1 if any(f[0] == "high" for f in r["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonCode ReviewTech Debt+2
F@f
0
活动座位表规划器
技能

根据宾客、桌型与约束生成座位表与冲突检查。

---
name: event-seating-chart-planner
description: 根据宾客、桌型与约束生成座位表与冲突检查。
---

# 活动座位表规划器

你是「活动座位表规划器」技能助手。上方 description 为中文任务说明。下方保留原技能完整正文(含脚本与代码);执行时遵循中文说明与原文技术细节。

# Event Seating Chart Planner

You help hosts turn a guest list into a seating chart without the spreadsheet agony. Seating is the job everyone leaves until the last week: the divorced uncle and aunt must not share a table, grandma needs a step-free spot by the door, the plus-one knows nobody, two RSVPs are still missing, the caterer wants vegan and child meals per table, and every change breaks something else. You collect the tables and the guest list, check the list for problems first, build a chart that meets the hard requests, explain the trade-offs and produce place cards and a caterer sheet.

You work only from the information the host gives you. You do not judge family politics: when two requests conflict, say so and ask the host which one wins. Keep the guest list private - do not repeat notes about relationships beyond what the chart needs.

## Files in this skill

- `scripts/seating_plan.py` - reads an event JSON (tables with seats, accessible, kids, fixed head-table guests; rules) and a guest CSV (name, party, group, rsvp, child, needs, diet, sit_with, keep_apart), merges parties and sit-with requests, places them table by table and improves the plan by moving and swapping groups; reports SC01-SC10 and prints the chart, a caterer summary and place cards; `--csv` place-card export, `--json`, `--include-pending` (Python 3.8+ standard library only)
- `references/seating-rules-and-etiquette.md` - how to collect the list, what counts as a hard or soft request, head table and family conventions, accessibility, children, dietary needs and room layout tips
- `references/checks-and-codes.md` - every finding code, its severity and what to do, plus the input format and how the planner scores a chart
- `templates/seating-brief.md` - the summary to send the host (and the venue / caterer)
- `examples/event.json` - a wedding dinner with a head table, an accessible table, four tables of 8 and a kids table
- `examples/guests.csv` - 32 rows with families, friends, work colleagues, a declined and a pending RSVP, a duplicate row, a misspelled sit-with name, a keep-apart pair, a wheelchair user and dietary needs
- `examples/seating-chart.md` - the real planner output on the example

## Workflow

### 1. Collect the tables and the list
Ask for the venue's table plan (number of tables, seats per table, which are step-free or near the exit, a kids table, the head table and who sits there). Ask for the guest list with RSVP, party (people who arrive together: couple, family), group (how they know the hosts: bride family, work, school friends), children, mobility or other needs, meal choice, and any "must sit with" or "keep apart" requests. Put them in `examples/event.json` and `examples/guests.csv` form.

### 2. Run the planner
```
python3 scripts/seating_plan.py examples/event.json examples/guests.csv
python3 scripts/seating_plan.py event.json guests.csv --include-pending
python3 scripts/seating_plan.py event.json guests.csv --csv > place-cards.csv
python3 scripts/seating_plan.py event.json guests.csv --json
python3 scripts/seating_plan.py --self-test
```
Exit 0 = all hard requests met, 1 = a hard request failed or the list has a high finding, 2 = bad input. Without a shell, seat fixed guests first, then the accessibility needs, then the largest groups, and check every keep-apart pair at the end.

### 3. Read the result
- High: SC01 not enough seats (or a group that fits nowhere), SC02 conflicting or broken keep-apart requests, SC03 names in requests or fixed seats that are not on the attending list (spelling suggestions shown).
- Medium: SC04 a guest at a table with nobody they know, SC05 a mobility need at a non-accessible table, SC08 duplicate rows, SC09 parties or requests too big for one table, SC10 a child without an adult from their party.
- Low/info: half-empty or empty tables (SC06), pending RSVPs seated provisionally (SC07).

### 4. Adjust with the host
Fix the list problems first (spelling, duplicates, RSVPs) and re-run. Then walk the host through each table: who is there and why, the lonely guests and the trade-offs. Make manual moves the host asks for and re-run to confirm the keep-apart pairs still hold. Fill `templates/seating-brief.md`.

### 5. Deliver
Return the chart per table, the open questions, the place-card list A-Z, the meal counts per table and in total, and the special needs list for the venue (wheelchair space, high chairs, allergies).

## Example result
29 guests attend (1 declined, 1 pending left out) for 44 seats. The couple, maid of honour, best man and Zeynep (who asked to sit with Selin) fill the head table. Grandma Fatma (wheelchair) sits at accessible Table 1 with the Kaya family; Linda and Richard Moore are kept apart (Tables 2 and 3); the work colleagues share Table 4 with the bride's friends. Sam's request to sit with "Olivr Grant" is a typo of Oliver Grant, Merve Sahin is listed twice, and neighbour Kemal knows nobody at his table. The kids table is empty because children stay with their parents.

## Rules

- Hard requests (keep apart, fixed seats, accessibility) beat soft ones (groups, balance); say clearly which request was dropped and why.
- Never split a couple or a parent and a young child unless the host asks.
- Do not guess at conflicts: ask the host, and keep sensitive notes out of anything printed.
- Re-run after every manual change; the chart is only valid for the list it was made from.
- Pending RSVPs are left out unless the host says to seat them; chase them before printing place cards.
FILE:references/seating-rules-and-etiquette.md
# Seating rules and etiquette

## Collecting the list
- One row per person, including children and plus-ones (use "Guest of <name>" until you have a name).
- Party = who arrives together and must sit together (couple, parent with young children). Group = how they know the hosts; it decides who shares a table.
- Ask the hosts directly for keep-apart pairs (exes, family feuds) and keep this column out of anything printed or shared with the venue.
- RSVP status: seat only confirmed guests; set a deadline for pending ones about 3 weeks before the event, when most venues need final numbers.

## Hard vs soft requests
- Hard: keep apart, fixed seats (head table, speakers, VIPs), accessibility, a young child next to a parent, capacity.
- Soft: sit with friends, tables by group, balanced tables, mixing groups at networking events.
When a soft request breaks a hard one, the hard one wins; tell the host.

## Common conventions
- Weddings: the head table (couple, wedding party, sometimes parents) faces the room; parents and grandparents at the tables closest to it; families of both sides near the front; friends and colleagues further back. Sweetheart table = couple only.
- Family dinners and birthdays: the guest of honour in the middle of the long side, with the people they see least often nearby.
- Corporate dinners: a host from the company at every table; mix departments if networking is the goal (`mix_groups: true`), seat speakers near the stage and the exit.
- Round tables of 8-10 work best for conversation; 12+ means people only talk to their neighbours.
- Avoid a table where one guest is the only one from their group - pair singles with someone outgoing or a similar-age group.

## Accessibility and needs
- Wheelchair users: a table on a step-free route near the door and the toilets, with a chair removed at the place (mark it on the room plan).
- Hearing impairment: away from speakers and the band, facing the room.
- Older guests: away from loudspeakers and the dance floor, near the exit and toilets.
- High chairs and booster seats: tell the venue per table.
- Allergies: list name, table and allergen for the caterer; a printed card at the place for severe allergies.

## Children
- Toddlers stay with their parents. A kids table works for ages about 5-12 when it is next to the parents' tables and has an adult or sitter nearby.
- Count child meals separately (and high chairs) in the caterer summary.

## Room layout tips
- Number tables in a way guests can find (signs on every table, an alphabetical escort-card list at the entrance).
- Keep the path from the door to the accessible table clear.
- Leave 1-2 empty seats across the room for late changes; venues usually allow 1-2 extra chairs per round table, but check the fire-safety capacity.

## Final week checklist
- Final RSVPs and meal choices -> re-run the planner -> host approval.
- Send the venue: table plan, guest count per table, high chairs, wheelchair spaces.
- Send the caterer: meals per table and in total, allergies with names and tables.
- Print place cards and the escort-card list from `--csv`.
FILE:references/checks-and-codes.md
# Checks and finding codes

| Code | Severity | Check | What to do |
|---|---|---|---|
| SC01 | high | more attending guests than seats, or a party/group that fits at no table with enough free seats | add a table or seats, confirm RSVPs, split the group over neighbouring tables |
| SC02 | high | a sit-with request joins two guests who must be kept apart (request dropped); keep-apart inside one party; a keep-apart pair seated together | ask the host which request wins |
| SC03 | high | a name in sit_with / keep_apart or a fixed seat is not on the attending list (did-you-mean suggestion) | fix the spelling or add the guest |
| SC04 | medium | guest at a table with fewer than `min_known` people from their party, group or sit-with list | swap, or seat them next to a sociable guest |
| SC05 | medium | wheelchair / step-free / walker / mobility / crutches need at a table not marked accessible | swap with someone at an accessible table |
| SC06 | low: table under half full; info: empty table | balance | merge tables or remove one |
| SC07 | info | RSVP pending, seated because of `--include-pending` | chase the RSVP |
| SC08 | medium | the same name twice (seated once), row without a name | check if two different people; add an initial |
| SC09 | medium | a party larger than the largest table (split), a sit-with group that would exceed the largest table (request dropped), a party split over tables | neighbouring tables; free seats at one table |
| SC10 | medium | child at a non-kids table without an adult from their party | move next to their parents |

## Verdict
- **N hard request(s) could not be met** - any high finding.
- **all hard requests met, N soft issue(s)** - medium findings only.
- **all requests met**.

## How the planner works
1. Attending guests only (rsvp yes; pending with `--include-pending`; no/declined skipped).
2. Parties are merged (when `keep_parties_together`), then sit-with requests, unless that joins a keep-apart pair or exceeds the largest table.
3. Groups are placed in order: fixed head-table guests (only they and their sit-with partners use a table with `fixed`), mobility needs, then by group size.
4. Each group goes to the table where the chart's penalty rises least; then the planner moves single groups, whole groups and swaps pairs while the penalty falls.
5. Penalty: keep-apart pair at one table 100, mobility need at a non-accessible table 40, a guest who knows fewer than `min_known` people 12, each extra group at a table 2 (unless `mix_groups`), each extra table a group is spread over 3, an adult at the kids table 3.
The plan is deterministic: the same input gives the same chart.

## Event JSON
`event, date, rules{keep_parties_together (true), mix_groups (false), min_known (1)}, tables[{name, seats, accessible, kids, fixed[names], notes}]`.

## Guest CSV
`name, party, group, rsvp (yes/no/pending), child (yes), needs (wheelchair; high chair; nut allergy), diet (vegetarian, vegan, gluten-free, child menu ...), sit_with (names separated by ;), keep_apart (names separated by ;)`. Only `name` is required; party defaults to the name, group to "-", rsvp to yes.
FILE:templates/seating-brief.md
# Seating brief: <event> (<date>)

**Guests:** <29 attending (1 declined, 1 pending)>   **Seats:** <44 at 6 tables>   **Status:** <draft 2 - waiting for 1 RSVP>

## Open questions for the host
- [ ] <Sam asked to sit with "Olivr Grant" - Oliver Grant? (seated together after the fix)>
- [ ] <Merve Sahin is on the list twice - one person or two?>
- [ ] <Kemal knows nobody at Table 3 - who should he sit next to?>
- [ ] <Chris Evans: RSVP pending - seat at Table 3 if he comes>

## Tables
| Table | Who | Notes |
|---|---|---|
| <Head table> | <Elif, Daniel, Selin, Tom, Zeynep> | <facing the room> |
| <Table 1 (accessible)> | <Kaya family, Grandma Fatma> | <wheelchair place on the aisle, high chair for Ela> |

## For the venue
- Tables and seat counts: <...>
- Wheelchair places: <Table 1 x1>   High chairs: <Table 1 x1>
- Remove: <kids table (empty)>

## For the caterer
| Table | Standard | Vegetarian | Vegan | Gluten-free | Child | Other |
|---|---|---|---|---|---|---|
| <Head table> | <3> | <2> | <0> | <0> | <0> | <-> |
| **Total** | <18> | <3> | <2> | <1> | <3> | <pescatarian 1, soft food 1> |

Allergies: <Max Hill (Table 2) - nut allergy>

## Place cards
<attach place-cards.csv from `seating_plan.py --csv`>
FILE:examples/event.json
{
  "event": "Elif and Daniel's wedding dinner",
  "date": "2027-06-12",
  "rules": {"keep_parties_together": true, "mix_groups": false, "min_known": 1},
  "tables": [
    {"name": "Head table", "seats": 6, "fixed": ["Elif Kaya", "Daniel Moore"], "notes": "facing the room"},
    {"name": "Table 1", "seats": 8, "accessible": true, "notes": "next to the terrace door, step-free"},
    {"name": "Table 2", "seats": 8},
    {"name": "Table 3", "seats": 8},
    {"name": "Table 4", "seats": 8},
    {"name": "Kids table", "seats": 6, "kids": true, "notes": "near Table 2, crayons and placemats"}
  ]
}
FILE:examples/guests.csv
name,party,group,rsvp,child,needs,diet,sit_with,keep_apart
Elif Kaya,Couple,couple,yes,,,vegetarian,,
Daniel Moore,Couple,couple,yes,,,standard,,
Selin Kaya,Maid of honour,bride friends,yes,,,standard,Elif Kaya,
Tom Moore,Best man,groom family,yes,,,standard,Daniel Moore,
Ayse Kaya,Kaya parents,bride family,yes,,,standard,,
Mehmet Kaya,Kaya parents,bride family,yes,,,standard,,
Fatma Yilmaz,Grandma Fatma,bride family,yes,,wheelchair,soft food,Ayse Kaya,
Can Kaya,Can family,bride family,yes,,,standard,,
Deniz Kaya,Can family,bride family,yes,,,vegan,,
Ela Kaya,Can family,bride family,yes,yes,high chair,child menu,,
Linda Moore,Linda,groom family,yes,,,gluten-free,,Richard Moore
Richard Moore,Richard and Jo,groom family,yes,,,standard,,Linda Moore
Jo Parker,Richard and Jo,groom family,yes,,,standard,,
Sarah Moore,Sarah family,groom family,yes,,,standard,,
James Hill,Sarah family,groom family,yes,,,standard,,
Lily Hill,Sarah family,groom family,yes,yes,,child menu,,
Max Hill,Sarah family,groom family,yes,yes,nut allergy,child menu,,
Zeynep Arslan,Zeynep,bride friends,yes,,,vegetarian,Selin Kaya,
Burak Demir,Burak and Ece,bride friends,yes,,,standard,,
Ece Demir,Burak and Ece,bride friends,yes,,,standard,,
Merve Sahin,Merve,bride friends,yes,,,vegan,,
Oliver Grant,Oliver and Priya,groom friends,yes,,,standard,,
Priya Grant,Oliver and Priya,groom friends,yes,,,vegetarian,,
Sam Turner,Sam,groom friends,yes,,,standard,Olivr Grant,
Chris Evans,Chris,groom friends,pending,,,standard,,
Nina Patel,Nina,work,yes,,,standard,,
Leo Fischer,Leo and Mia,work,yes,,,standard,,
Mia Fischer,Leo and Mia,work,yes,,,pescatarian,,
Hannah Lee,Hannah,work,yes,,,standard,,
Peter Novak,Peter,work,no,,,standard,,
Kemal Ozturk,Kemal,neighbours,yes,,,standard,,
Merve Sahin,Merve,bride friends,yes,,,vegan,,
FILE:examples/seating-chart.md
# Seating chart: Elif and Daniel's wedding dinner (2027-06-12)

29 attending guest(s) (1 declined; 1 pending left out), 44 seat(s) at 6 table(s). Source: examples/guests.csv.

**Verdict: 1 hard request(s) could not be met - see findings**

Findings: 1 high, 2 medium, 0 low, 1 info.

## Tables

| Table | Seats | Guests | Groups | Meals |
|---|---|---|---|---|
| Head table [fixed] | 5/6 | Tom Moore, Elif Kaya, Daniel Moore, Selin Kaya, Zeynep Arslan | bride friends 2, couple 2, groom family 1 | standard 3, vegetarian 2 |
| Table 1 [accessible] | 6/8 | Can Kaya, Deniz Kaya, Ela Kaya (child), Fatma Yilmaz *, Ayse Kaya, Mehmet Kaya | bride family 6 | child menu (child) 1, soft food 1, standard 3, vegan 1 |
| Table 2 | 5/8 | Linda Moore, Sarah Moore, James Hill, Lily Hill (child), Max Hill (child) | groom family 5 | child menu (child) 2, gluten-free 1, standard 2 |
| Table 3 | 6/8 | Kemal Ozturk, Oliver Grant, Priya Grant, Richard Moore, Jo Parker, Sam Turner | groom family 2, groom friends 3, neighbours 1 | standard 5, vegetarian 1 |
| Table 4 | 7/8 | Burak Demir, Ece Demir, Hannah Lee, Leo Fischer, Mia Fischer, Merve Sahin, Nina Patel | bride friends 3, work 4 | pescatarian 1, standard 5, vegan 1 |
| Kids table [kids] | 0/6 | - | - | - |

`*` = mobility need (seat on the aisle side, nearest the door).

## Findings

| Sev | Code | Who | Finding | Fix |
|---|---|---|---|---|
| high | SC03 | Sam Turner | sit_with 'Olivr Grant' is not on the (attending) guest list - did you mean Oliver Grant? | fix the spelling |
| medium | SC04 | Kemal Ozturk | at Table 3 without anyone from their party or group (neighbours) | seat them next to the most sociable guest there, or swap |
| medium | SC08 | Merve Sahin | listed twice (lines 22 and 33) - seated once | check whether these are two different people; add a surname or initial |
| info | SC06 | Kids table | empty table | remove it from the room plan |

## Caterer summary

| Meal | Count |
|---|---|
| child menu (child) | 3 |
| gluten-free | 1 |
| pescatarian | 1 |
| soft food | 1 |
| standard | 18 |
| vegan | 2 |
| vegetarian | 3 |

Special needs: Ela Kaya (Table 1) - high chair; Fatma Yilmaz (Table 1) - wheelchair; Max Hill (Table 2) - nut allergy.

## Place cards (A-Z)

| Guest | Table |
|---|---|
| Ayse Kaya | Table 1 |
| Burak Demir | Table 4 |
| Can Kaya | Table 1 |
| Daniel Moore | Head table |
| Deniz Kaya | Table 1 |
| Ece Demir | Table 4 |
| Ela Kaya | Table 1 |
| Elif Kaya | Head table |
| Fatma Yilmaz | Table 1 |
| Hannah Lee | Table 4 |
| James Hill | Table 2 |
| Jo Parker | Table 3 |
| Kemal Ozturk | Table 3 |
| Leo Fischer | Table 4 |
| Lily Hill | Table 2 |
| Linda Moore | Table 2 |
| Max Hill | Table 2 |
| Mehmet Kaya | Table 1 |
| Merve Sahin | Table 4 |
| Mia Fischer | Table 4 |
| Nina Patel | Table 4 |
| Oliver Grant | Table 3 |
| Priya Grant | Table 3 |
| Richard Moore | Table 3 |
| Sam Turner | Table 3 |
| Sarah Moore | Table 2 |
| Selin Kaya | Head table |
| Tom Moore | Head table |
| Zeynep Arslan | Head table |

The chart is a starting point: move people by hand where you know better, then re-run to check the requests still hold.
FILE:scripts/seating_plan.py
#!/usr/bin/env python3
"""Event seating chart planner (stdlib only, Python 3.8+).

Reads an event file (tables with seat counts, accessible / kids / fixed head-table seats, rules)
and a guest list CSV (name, party, group, RSVP, child, needs, diet, sit_with, keep_apart) and
builds a seating chart: parties stay together, "sit with" requests share a table, "keep apart"
pairs never share one, guests with mobility needs go to accessible tables, nobody is seated
without someone they know, and tables are filled by group where possible. Checks the list first
(unknown or misspelled names, duplicates, impossible or conflicting requests, not enough seats)
and prints the chart, place cards and a per-table meal count for the caterer.

Usage:
  seating_plan.py EVENT.json GUESTS.csv [--include-pending] [--csv] [--json]
  seating_plan.py --self-test
Exit: 0 = all hard requests met, 1 = a hard request could not be met (or a high finding), 2 = bad input.
"""
import argparse, csv, difflib, io, json, sys

SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
EVENT_KEYS = {"event", "date", "tables", "rules"}
TABLE_KEYS = {"name", "seats", "accessible", "kids", "fixed", "notes"}
RULE_KEYS = {"keep_parties_together", "mix_groups", "min_known"}
MOBILITY = ("wheelchair", "step-free", "walker", "mobility", "crutches")
W_APART, W_ACCESS, W_LONELY, W_GROUP, W_SPREAD = 100, 40, 12, 2, 3


class InputError(Exception):
    pass


def norm(s):
    return " ".join(str(s or "").split()).lower()


def split_list(s):
    return [x.strip() for x in str(s or "").replace(",", ";").split(";") if x.strip()]


# ---------------------------------------------------------------- load
def load_event(cfg):
    if not isinstance(cfg, dict) or not isinstance(cfg.get("tables"), list) or not cfg["tables"]:
        raise InputError("event file must be a JSON object with a non-empty \"tables\" list")
    bad = set(cfg) - EVENT_KEYS
    if bad:
        raise InputError("unknown key(s) in event file: %s" % ", ".join(sorted(bad)))
    rules = cfg.get("rules") or {}
    badr = set(rules) - RULE_KEYS
    if badr:
        raise InputError("unknown rule(s): %s" % ", ".join(sorted(badr)))
    tables, names = [], set()
    for i, t in enumerate(cfg["tables"], 1):
        badt = set(t) - TABLE_KEYS
        if badt:
            raise InputError("table #%d: unknown key(s) %s" % (i, ", ".join(sorted(badt))))
        name = str(t.get("name") or "Table %d" % i)
        if name in names:
            raise InputError("table name %r used twice" % name)
        names.add(name)
        seats = t.get("seats")
        if not isinstance(seats, int) or seats < 1:
            raise InputError("table %s: seats must be a positive whole number, got %r" % (name, seats))
        tables.append({"name": name, "seats": seats, "accessible": bool(t.get("accessible")), "kids": bool(t.get("kids")),
                       "fixed": [str(x) for x in t.get("fixed") or []], "notes": t.get("notes", "")})
    return {"event": cfg.get("event", "Event"), "date": cfg.get("date", ""), "tables": tables,
            "together": rules.get("keep_parties_together", True), "mix": bool(rules.get("mix_groups", False)),
            "min_known": int(rules.get("min_known", 1))}


def load_guests(text, include_pending=False):
    rows = list(csv.DictReader(io.StringIO(text)))
    if not rows:
        raise InputError("guest list is empty")
    cols = {c.strip().lower() for c in rows[0].keys() if c}
    if "name" not in cols:
        raise InputError("guest list needs a 'name' column")
    guests, skipped, F, seen = [], {"no": 0, "pending": 0}, [], {}
    for ln, r in enumerate(rows, 2):
        r = {(k or "").strip().lower(): (v or "").strip() for k, v in r.items()}
        name = " ".join(r.get("name", "").split())
        if not name:
            F.append(("medium", "SC08", "line %d" % ln, "row without a name skipped", "fill in the name or delete the row"))
            continue
        rsvp = norm(r.get("rsvp") or "yes")
        if rsvp in ("no", "declined", "n"):
            skipped["no"] += 1
            continue
        if rsvp in ("pending", "maybe", "?", "invited", ""):
            if not include_pending:
                skipped["pending"] += 1
                continue
        if norm(name) in seen:
            F.append(("medium", "SC08", name, "listed twice (lines %d and %d) - seated once" % (seen[norm(name)], ln), "check whether these are two different people; add a surname or initial"))
            continue
        seen[norm(name)] = ln
        needs = [norm(x) for x in split_list(r.get("needs"))]
        guests.append({"name": name, "party": r.get("party") or name, "group": r.get("group") or "-",
                       "child": norm(r.get("child")) in ("yes", "y", "true", "1", "child"),
                       "needs": needs, "mobility": any(any(m in n for m in MOBILITY) for n in needs),
                       "diet": norm(r.get("diet")) or "standard", "sit_with": split_list(r.get("sit_with")),
                       "keep_apart": split_list(r.get("keep_apart")), "line": ln, "pending": rsvp != "yes" and rsvp not in ("y", "accepted")})
    return guests, skipped, F


# ---------------------------------------------------------------- plan
class DSU:
    def __init__(self, n):
        self.p = list(range(n))
    def find(self, a):
        while self.p[a] != a:
            self.p[a] = self.p[self.p[a]]
            a = self.p[a]
        return a
    def union(self, a, b):
        self.p[self.find(a)] = self.find(b)


def plan(ev, guests, F=None):
    F = list(F or [])
    add = lambda sev, code, who, msg, fix: F.append((sev, code, who, msg, fix))
    idx = {norm(g["name"]): i for i, g in enumerate(guests)}
    allnames = [g["name"] for g in guests]
    def resolve(who, ref, kind):
        j = idx.get(norm(ref))
        if j is None:
            close = difflib.get_close_matches(ref, allnames, 1, 0.7)
            add("high", "SC03", who, "%s %r is not on the (attending) guest list%s" % (kind, ref, " - did you mean %s?" % close[0] if close else ""),
                "fix the spelling" if close else "add the guest or remove the request")
        return j
    n = len(guests)
    seats = sum(t["seats"] for t in ev["tables"])
    maxt = max(t["seats"] for t in ev["tables"])
    if n > seats:
        add("high", "SC01", "-", "%d guests but only %d seats (%d short)" % (n, seats, n - seats), "add a table or seats, or confirm the RSVPs")
    dsu = DSU(n)
    party_of = {}
    for i, g in enumerate(guests):
        party_of.setdefault(norm(g["party"]), []).append(i)
    if ev["together"]:
        for p, mem in party_of.items():
            if len(mem) > maxt:
                add("medium", "SC09", guests[mem[0]]["party"], "party of %d is larger than the largest table (%d) - it is split" % (len(mem), maxt), "seat the party on neighbouring tables")
                for a, b in zip(mem, mem[1:]):
                    if (mem.index(b)) % maxt:
                        dsu.union(a, b)
            else:
                for a, b in zip(mem, mem[1:]):
                    dsu.union(a, b)
    apart = set()
    for i, g in enumerate(guests):
        for ref in g["keep_apart"]:
            j = resolve(g["name"], ref, "keep_apart")
            if j is not None and j != i:
                apart.add(tuple(sorted((i, j))))
    # sit_with links, refusing those that would exceed the largest table or join a keep-apart pair
    for i, g in enumerate(guests):
        for ref in g["sit_with"]:
            j = resolve(g["name"], ref, "sit_with")
            if j is None or j == i:
                continue
            a, b = dsu.find(i), dsu.find(j)
            if a == b:
                continue
            size = sum(1 for k in range(n) if dsu.find(k) in (a, b))
            clash = [p for p in apart if {dsu.find(p[0]), dsu.find(p[1])} == {a, b}]
            if clash:
                x, y = clash[0]
                add("high", "SC02", g["name"], "asks to sit with %s, but %s and %s must be kept apart - the request is dropped" % (guests[j]["name"], guests[x]["name"], guests[y]["name"]),
                    "ask the host which request wins")
                continue
            if size > maxt:
                add("medium", "SC09", g["name"], "sitting with %s would make a group of %d (largest table %d) - request dropped" % (guests[j]["name"], size, maxt), "seat them on neighbouring tables")
                continue
            dsu.union(i, j)
    for x, y in sorted(apart):
        if dsu.find(x) == dsu.find(y):
            add("high", "SC02", guests[x]["name"], "must be kept apart from %s but they are in the same party - cannot be met" % guests[y]["name"], "change the party or the request")
    links = set()
    for i, g in enumerate(guests):
        for ref in g["sit_with"]:
            j = idx.get(norm(ref))
            if j is not None:
                links.add((i, j)); links.add((j, i))
    def knows(a, b):
        ga, gb = guests[a], guests[b]
        return (norm(ga["party"]) == norm(gb["party"]) or (ga["group"] == gb["group"] and ga["group"] != "-") or (a, b) in links)
    clusters = {}
    for i in range(n):
        clusters.setdefault(dsu.find(i), []).append(i)
    cl = list(clusters.values())
    T = ev["tables"]
    fixed_at = {}
    for ti, t in enumerate(T):
        for nm in t["fixed"]:
            j = idx.get(norm(nm))
            if j is None:
                close = difflib.get_close_matches(nm, allnames, 1, 0.7)
                add("high", "SC03", t["name"], "fixed seat for %r who is not on the attending list%s" % (nm, " - did you mean %s?" % close[0] if close else ""), "fix the name in the event file")
            else:
                fixed_at[j] = ti
    assign = {}
    load = [0] * len(T)
    reserved = {ti for ti, t in enumerate(T) if t["fixed"]}
    def pen_table(ti, members):
        p = 0
        mem = set(members)
        for x, y in apart:
            if x in mem and y in mem:
                p += W_APART
        groups = {}
        for m in members:
            g = guests[m]
            if g["mobility"] and not T[ti]["accessible"]:
                p += W_ACCESS
            groups[g["group"]] = groups.get(g["group"], 0) + 1
        if not ev["mix"]:
            p += W_GROUP * max(0, len(groups) - 1)
        for m in members:
            g = guests[m]
            known = sum(1 for o in members if o != m and knows(m, o))
            if known < ev["min_known"]:
                p += W_LONELY
        if T[ti]["kids"]:
            p += sum(3 for m in members if not guests[m]["child"])
        return p
    def members_of(ti):
        return [m for c, t in assign.items() if t == ti for m in cl[c]]
    def total():
        p = sum(pen_table(ti, members_of(ti)) for ti in range(len(T)))
        spread = {}
        for c, ti in assign.items():
            for m in cl[c]:
                if guests[m]["group"] != "-":
                    spread.setdefault(guests[m]["group"], set()).add(ti)
        return p + W_SPREAD * sum(len(v) - 1 for v in spread.values())
    gsize = {}
    for g in guests:
        gsize[g["group"]] = gsize.get(g["group"], 0) + 1
    order = sorted(range(len(cl)), key=lambda c: (-any(i in fixed_at for i in cl[c]), -any(guests[i]["mobility"] for i in cl[c]),
                                                  -gsize[guests[cl[c][0]]["group"]], guests[cl[c][0]]["group"], -len(cl[c]), cl[c][0]))
    unseated = []
    for c in order:
        size = len(cl[c])
        fx = [fixed_at[i] for i in cl[c] if i in fixed_at]
        cands = [fx[0]] if fx else range(len(T))
        best = None
        for ti in cands:
            if load[ti] + size > T[ti]["seats"] or (T[ti]["fixed"] and not fx and ti in reserved):
                continue
            assign[c] = ti
            delta = total()
            del assign[c]
            key = (delta, -(load[ti] > 0), ti)
            if best is None or key < best[0]:
                best = (key, ti)
        if best is None:
            unseated.append(c)
            continue
        assign[c] = best[1]
        load[best[1]] += size
    # local search: move or swap clusters while it lowers the penalty (deterministic)
    fixed_cl = {c for c in assign if any(i in fixed_at for i in cl[c])}
    improved, rounds = True, 0
    while improved and rounds < 50:
        improved, rounds = False, rounds + 1
        cur = total()
        for c in sorted(assign):
            if c in fixed_cl:
                continue
            for ti in range(len(T)):
                if ti == assign[c] or load[ti] + len(cl[c]) > T[ti]["seats"] or ti in reserved:
                    continue
                old = assign[c]
                assign[c] = ti
                new = total()
                if new < cur:
                    load[old] -= len(cl[c]); load[ti] += len(cl[c]); cur = new; improved = True
                else:
                    assign[c] = old
        # move every cluster of one group (from all tables) to one table
        for gname in sorted({guests[cl[c][0]]["group"] for c in assign if c not in fixed_cl}):
            block = [c for c in assign if c not in fixed_cl and guests[cl[c][0]]["group"] == gname]
            for b in range(len(T)):
                if b in reserved:
                    continue
                old = {c: assign[c] for c in block}
                moving = sum(len(cl[c]) for c in block if old[c] != b)
                if not moving or load[b] + moving > T[b]["seats"]:
                    continue
                for c in block:
                    assign[c] = b
                new = total()
                if new < cur:
                    for c in block:
                        if old[c] != b:
                            load[old[c]] -= len(cl[c]); load[b] += len(cl[c])
                    cur = new; improved = True
                    break
                for c in block:
                    assign[c] = old[c]
        # move a whole group block (all clusters of one group at one table) to another table
        for a in range(len(T)):
            groups_here = sorted({guests[cl[c][0]]["group"] for c, x in assign.items() if x == a and c not in fixed_cl})
            for gname in groups_here:
                block = [c for c, x in assign.items() if x == a and c not in fixed_cl and guests[cl[c][0]]["group"] == gname]
                bsize = sum(len(cl[c]) for c in block)
                for b in range(len(T)):
                    if b == a or b in reserved or load[b] + bsize > T[b]["seats"]:
                        continue
                    for c in block:
                        assign[c] = b
                    new = total()
                    if new < cur:
                        load[a] -= bsize; load[b] += bsize; cur = new; improved = True
                        break
                    for c in block:
                        assign[c] = a
        for c in sorted(assign):
            for d in sorted(assign):
                if d <= c or c in fixed_cl or d in fixed_cl or assign[c] == assign[d] or assign[c] in reserved or assign[d] in reserved:
                    continue
                a, b = assign[c], assign[d]
                if load[a] - len(cl[c]) + len(cl[d]) > T[a]["seats"] or load[b] - len(cl[d]) + len(cl[c]) > T[b]["seats"]:
                    continue
                assign[c], assign[d] = b, a
                new = total()
                if new < cur:
                    load[a] += len(cl[d]) - len(cl[c]); load[b] += len(cl[c]) - len(cl[d]); cur = new; improved = True
                else:
                    assign[c], assign[d] = a, b
    seat = {}
    for c, ti in assign.items():
        for i in cl[c]:
            seat[i] = ti
    for c in unseated:
        add("high", "SC01", ", ".join(guests[i]["name"] for i in cl[c]), "no table with %d free seats together - not seated" % len(cl[c]), "add seats or split the group across neighbouring tables")
    # findings on the result
    for x, y in sorted(apart):
        if x in seat and y in seat and seat[x] == seat[y]:
            add("high", "SC02", guests[x]["name"], "seated at %s together with %s although they must be kept apart" % (T[seat[x]]["name"], guests[y]["name"]), "move one of them by hand")
    for i, g in enumerate(guests):
        if i not in seat:
            continue
        ti = seat[i]
        if g["mobility"] and not T[ti]["accessible"]:
            add("medium", "SC05", g["name"], "needs %s but %s is not marked accessible" % ("/".join(g["needs"]), T[ti]["name"]), "swap with a guest at an accessible table")
        mem = [m for m, t in seat.items() if t == ti and m != i]
        known = [m for m in mem if knows(i, m)]
        if len(known) < ev["min_known"]:
            add("medium", "SC04", g["name"], "at %s without anyone from their party or group (%s)" % (T[ti]["name"], g["group"]), "seat them next to the most sociable guest there, or swap")
        if g["child"] and not T[ti]["kids"] and not any(norm(guests[m]["party"]) == norm(g["party"]) and not guests[m]["child"] for m in mem):
            add("medium", "SC10", g["name"], "child seated without an adult from their party", "move next to their parents or to the kids table")
        if g["pending"]:
            add("info", "SC07", g["name"], "RSVP still pending - seated provisionally", "chase the RSVP before printing place cards")
    for ti, t in enumerate(T):
        used = sum(1 for i in seat if seat[i] == ti)
        if 0 < used < t["seats"] / 2:
            add("low", "SC06", t["name"], "only %d of %d seats used" % (used, t["seats"]), "merge with another table or take it out")
        if used == 0:
            add("info", "SC06", t["name"], "empty table", "remove it from the room plan")
    for p, mem in party_of.items():
        ts = {seat[i] for i in mem if i in seat}
        if len(ts) > 1 and len(mem) <= maxt:
            add("medium", "SC09", guests[mem[0]]["party"], "party split over %s" % ", ".join(T[x]["name"] for x in sorted(ts)), "free seats at one table and move them together")
    F.sort(key=lambda x: (SEV[x[0]], x[1], x[2]))
    return {"seat": seat, "tables": T, "guests": guests, "findings": F, "penalty": total(), "seats": seats}


# ---------------------------------------------------------------- output
def report(ev, r, skipped, src):
    G, T, seat = r["guests"], r["tables"], r["seat"]
    c = {s: 0 for s in SEV}
    for f in r["findings"]:
        c[f[0]] += 1
    hard = [f for f in r["findings"] if f[0] == "high"]
    out = ["# Seating chart: %s%s" % (ev["event"], (" (%s)" % ev["date"]) if ev["date"] else ""), "",
           "%d attending guest(s) (%d declined; %s), %d seat(s) at %d table(s). Source: %s." % (
               len(G), skipped["no"], ("%d pending seated provisionally" % sum(1 for g in G if g["pending"])) if any(g["pending"] for g in G)
               else "%d pending left out" % skipped["pending"], r["seats"], len(T), src), ""]
    v = ("%d hard request(s) could not be met - see findings" % len(hard)) if hard else ("all hard requests met, %d soft issue(s) to look at" % c["medium"] if c["medium"] else "all requests met")
    out += ["**Verdict: %s**" % v, "", "Findings: %d high, %d medium, %d low, %d info." % (c["high"], c["medium"], c["low"], c["info"]), ""]
    out += ["## Tables", "", "| Table | Seats | Guests | Groups | Meals |", "|---|---|---|---|---|"]
    for ti, t in enumerate(T):
        mem = sorted((i for i in seat if seat[i] == ti), key=lambda i: (norm(G[i]["party"]), G[i]["child"], G[i]["line"]))
        tags = [x for x in ("accessible" if t["accessible"] else "", "kids" if t["kids"] else "", "fixed" if t["fixed"] else "") if x]
        names = ", ".join(G[i]["name"] + (" (child)" if G[i]["child"] else "") + (" *" if G[i]["mobility"] else "") for i in mem) or "-"
        groups = {}
        meals = {}
        for i in mem:
            groups[G[i]["group"]] = groups.get(G[i]["group"], 0) + 1
            k = G[i]["diet"] + (" (child)" if G[i]["child"] else "")
            meals[k] = meals.get(k, 0) + 1
        out.append("| %s%s | %d/%d | %s | %s | %s |" % (t["name"], " [%s]" % ", ".join(tags) if tags else "", len(mem), t["seats"], names,
                                                     ", ".join("%s %d" % kv for kv in sorted(groups.items())) or "-", ", ".join("%s %d" % kv for kv in sorted(meals.items())) or "-"))
    out += ["", "`*` = mobility need (seat on the aisle side, nearest the door).", ""]
    out += ["## Findings", "", "| Sev | Code | Who | Finding | Fix |", "|---|---|---|---|---|"]
    for s, code, who, msg, fix in r["findings"]:
        out.append("| %s | %s | %s | %s | %s |" % (s, code, who, msg.replace("|", "\\|"), fix.replace("|", "\\|")))
    meals = {}
    for i in seat:
        k = G[i]["diet"] + (" (child)" if G[i]["child"] else "")
        meals[k] = meals.get(k, 0) + 1
    out += ["", "## Caterer summary", "", "| Meal | Count |", "|---|---|"]
    for k, v_ in sorted(meals.items()):
        out.append("| %s | %d |" % (k, v_))
    notes = [(G[i]["name"], T[seat[i]]["name"], ", ".join(x for x in G[i]["needs"])) for i in sorted(seat, key=lambda i: G[i]["name"]) if G[i]["needs"]]
    if notes:
        out += ["", "Special needs: " + "; ".join("%s (%s) - %s" % n for n in notes) + "."]
    out += ["", "## Place cards (A-Z)", "", "| Guest | Table |", "|---|---|"]
    for i in sorted(seat, key=lambda i: norm(G[i]["name"])):
        out.append("| %s | %s |" % (G[i]["name"], T[seat[i]]["name"]))
    out += ["", "The chart is a starting point: move people by hand where you know better, then re-run to check the requests still hold."]
    return "\n".join(out) + "\n"


def run(event_path, guests_path, include_pending=False):
    try:
        ev = load_event(json.load(open(event_path, encoding="utf-8")))
        text = open(guests_path, encoding="utf-8-sig").read()
    except (OSError, ValueError) as e:
        raise InputError("cannot read input: %s" % e)
    guests, skipped, F = load_guests(text, include_pending)
    return ev, plan(ev, guests, F), skipped


# ---------------------------------------------------------------- self-test
def self_test():
    tests = []
    def t(name, cond):
        tests.append((name, bool(cond)))
    H = "name,party,group,rsvp,child,needs,diet,sit_with,keep_apart\n"
    def P(tables, rows, pending=False, **rules):
        ev = load_event({"tables": tables, "rules": rules})
        g, sk, F = load_guests(H + rows, pending)
        return plan(ev, g, F), g
    def codes(r):
        return [(f[0], f[1]) for f in r["findings"]]
    def tab(r, g, name):
        i = [k for k, x in enumerate(g) if x["name"] == name][0]
        return r["seat"].get(i)
    T2 = [{"name": "A", "seats": 4}, {"name": "B", "seats": 4}]
    rows = "Ann,P1,work,yes\nBob,P1,work,yes\nCid,P2,family,yes\nDee,P2,family,yes\nEve,P3,work,yes\nFay,P4,family,yes\n"
    r, g = P(T2, rows)
    t("parties stay together", tab(r, g, "Ann") == tab(r, g, "Bob") and tab(r, g, "Cid") == tab(r, g, "Dee"))
    t("groups fill tables", tab(r, g, "Eve") == tab(r, g, "Ann") and tab(r, g, "Fay") == tab(r, g, "Cid"))
    t("clean plan has no high/medium", not [f for f in r["findings"] if f[0] in ("high", "medium")])
    r, g = P(T2, "Ann,P1,work,yes,,,,,Bob\nBob,P2,work,yes\nCid,P3,work,yes\nDee,P4,work,yes\n")
    t("keep_apart respected", tab(r, g, "Ann") != tab(r, g, "Bob") and ("high", "SC02") not in codes(r))
    r, g = P(T2, "Ann,P1,work,yes,,,,Dee\nBob,P2,family,yes\nCid,P3,family,yes\nDee,P4,friends,yes\n")
    t("sit_with respected", tab(r, g, "Ann") == tab(r, g, "Dee"))
    r, g = P(T2, "Ann,P1,work,yes,,,,Bob,Bob\nBob,P2,work,yes\n")
    t("sit_with vs keep_apart conflict -> SC02 high", ("high", "SC02") in codes(r))
    r, g = P(T2, "Ann,P1,work,yes,,,,,Bob\nBob,P1,work,yes\n")
    t("keep apart inside one party -> SC02", ("high", "SC02") in codes(r))
    r, g = P(T2, "Ann,P1,work,yes,,,,Bobb\nBob,P2,work,yes\n")
    t("misspelled name -> SC03 with suggestion", any(f[1] == "SC03" and "did you mean Bob" in f[3] for f in r["findings"]))
    r, g = P([{"name": "A", "seats": 2}], "Ann,P1,w,yes\nBob,P2,w,yes\nCid,P3,w,yes\n")
    t("not enough seats -> SC01 high", ("high", "SC01") in codes(r))
    r, g = P(T2, "Ann,P1,w,yes\nAnn,P1,w,yes\nBob,P1,w,yes\n")
    t("duplicate -> SC08 and seated once", ("medium", "SC08") in codes(r) and len(g) == 2)
    r, g = P(T2, "Ann,P1,w,no\nBob,P2,w,pending\nCid,P3,w,yes\nDee,P3,w,yes\n")
    t("declined and pending excluded", len(g) == 2)
    r, g = P(T2, "Bob,P2,w,pending\nCid,P3,w,yes\n", pending=True)
    t("--include-pending seats pending -> SC07", len(g) == 2 and ("info", "SC07") in codes(r))
    r, g = P([{"name": "A", "seats": 4}, {"name": "B", "seats": 4, "accessible": True}], "Ann,P1,w,yes\nBob,P2,x,yes,,wheelchair\n")
    t("wheelchair -> accessible table", tab(r, g, "Bob") == 1)
    r, g = P([{"name": "A", "seats": 4}, {"name": "B", "seats": 4}], "Ann,P1,w,yes\nBob,P2,x,yes,,wheelchair\n")
    t("no accessible table -> SC05", ("medium", "SC05") in codes(r))
    r, g = P(T2, "Ann,P1,work,yes\nBob,P1,work,yes\nZed,P9,gym,yes\n")
    t("lonely guest -> SC04", ("medium", "SC04") in codes(r))
    r, g = P([{"name": "Head", "seats": 4, "fixed": ["Bride", "Groom"]}, {"name": "B", "seats": 4}], "Bride,C,couple,yes\nGroom,C,couple,yes\nBest,Best,groom family,yes,,,,Groom\n")
    t("sit_with partner counts as someone they know", ("medium", "SC04") not in codes(r) and tab(r, g, "Best") == 0)
    r, g = P([{"name": "A", "seats": 4}, {"name": "B", "seats": 4}, {"name": "C", "seats": 4}],
             "A1,a1,fam,yes\nA2,a1,fam,yes\nA3,a1,fam,yes\nW1,w1,work,yes\nB1,b1,fam,yes\nB2,b1,fam,yes\nB3,b1,fam,yes\nW2,w2,work,yes\n")
    t("group split by greedy is pulled together", tab(r, g, "W1") == tab(r, g, "W2"))
    r, g = P([{"name": "A", "seats": 3}, {"name": "B", "seats": 3}], "A1,Big,w,yes\nA2,Big,w,yes\nA3,Big,w,yes\nA4,Big,w,yes\n")
    t("party bigger than a table is split -> SC09", ("medium", "SC09") in codes(r) and len(r["seat"]) == 4)
    r, g = P([{"name": "Head", "seats": 2, "fixed": ["Cid"]}, {"name": "B", "seats": 4}], "Ann,P1,w,yes\nBob,P1,w,yes\nCid,P3,w,yes\n")
    t("fixed seat honoured", tab(r, g, "Cid") == 0)
    r, g = P([{"name": "Head", "seats": 2, "fixed": ["Cyd"]}, {"name": "B", "seats": 4}], "Cid,P3,w,yes\n")
    t("fixed seat typo -> SC03", ("high", "SC03") in codes(r))
    r, g = P([{"name": "A", "seats": 10}, {"name": "B", "seats": 10}], "Ann,P1,w,yes\nBob,P1,w,yes\n")
    t("half-empty and empty tables -> SC06", ("low", "SC06") in codes(r) and ("info", "SC06") in codes(r))
    r, g = P([{"name": "A", "seats": 4}, {"name": "Kids", "seats": 4, "kids": True}], "Mum,F,fam,yes\nTim,F,fam,yes,yes\nLia,G,fam,yes,yes\nDad,G,fam,yes\n")
    t("children stay with their party by default", tab(r, g, "Tim") == tab(r, g, "Mum"))
    r, g = P(T2, "Ann,P1,w,yes,,,vegetarian\nBob,P1,w,yes,,,\n")
    t("diet counted", "vegetarian 1" in report({"event": "x", "date": ""}, r, {"no": 0, "pending": 0}, "x"))
    r, g = P([{"name": "A", "seats": 2}, {"name": "B", "seats": 2}], "Ann,P1,w,yes,,,,Bob;Cid\nBob,P2,w,yes\nCid,P3,w,yes\n")
    t("sit_with group larger than any table -> SC09", ("medium", "SC09") in codes(r))
    for bad, name in (({"tables": []}, "no tables"), ({"tables": [{"name": "A", "seats": 0}]}, "zero seats"),
                      ({"tables": [{"name": "A", "seats": 4}, {"name": "A", "seats": 4}]}, "duplicate table"),
                      ({"tables": [{"name": "A", "seats": 4, "colour": "red"}]}, "unknown table key")):
        try:
            load_event(bad); t("rejected: " + name, False)
        except InputError:
            t("rejected: " + name, True)
    try:
        load_guests("first,last\nA,B\n"); t("rejected: no name column", False)
    except InputError:
        t("rejected: no name column", True)
    r, g = P(T2, rows)
    rep = report({"event": "x", "date": ""}, r, {"no": 0, "pending": 0}, "x")
    t("report has verdict, tables and place cards", "**Verdict:" in rep and "## Tables" in rep and "## Place cards" in rep)
    for n, ok in tests:
        print("%s  %s" % ("PASS" if ok else "FAIL", n))
    p = sum(ok for _, ok in tests)
    print("%d/%d passed" % (p, len(tests)))
    return 0 if p == len(tests) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Build a seating chart from tables and a guest list.")
    ap.add_argument("event", nargs="?")
    ap.add_argument("guests", nargs="?")
    ap.add_argument("--include-pending", action="store_true", help="seat guests whose RSVP is pending")
    ap.add_argument("--csv", action="store_true", help="print name,table CSV (place cards)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.event or not a.guests:
        ap.error("give EVENT.json and GUESTS.csv")
    try:
        ev, r, skipped = run(a.event, a.guests, a.include_pending)
    except InputError as e:
        print("error: %s" % e, file=sys.stderr)
        return 2
    G, T = r["guests"], r["tables"]
    if a.json:
        print(json.dumps({"tables": [{"name": t["name"], "seats": t["seats"], "guests": [G[i]["name"] for i in sorted(r["seat"]) if r["seat"][i] == ti]} for ti, t in enumerate(T)],
                          "unseated": [g["name"] for i, g in enumerate(G) if i not in r["seat"]],
                          "findings": [dict(zip(("severity", "code", "who", "finding", "fix"), f)) for f in r["findings"]]}, indent=2))
    elif a.csv:
        w = csv.writer(sys.stdout, lineterminator="\n")
        w.writerow(["name", "table", "diet"])
        for i in sorted(r["seat"], key=lambda i: norm(G[i]["name"])):
            w.writerow([G[i]["name"], T[r["seat"][i]]["name"], G[i]["diet"]])
    else:
        sys.stdout.write(report(ev, r, skipped, a.guests))
    return 1 if any(f[0] == "high" for f in r["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonPlanningOrganization+2
F@f
0
Postgres EXPLAIN 慢查询分析器
技能

阅读 EXPLAIN / EXPLAIN ANALYZE(文本或 JSON),定位顺序扫、坏连接、缺失索引等原因。

---
name: postgres-explain-slow-query-analyzer
description: 阅读 EXPLAIN / EXPLAIN ANALYZE(文本或 JSON),定位顺序扫、坏连接、缺失索引等原因。
---

# Postgres EXPLAIN 慢查询分析器

你是「Postgres EXPLAIN 慢查询分析器」技能助手。上方 description 为中文任务说明。下方保留原技能完整正文(含脚本与代码);执行时遵循中文说明与原文技术细节。

# Postgres EXPLAIN Slow Query Analyzer

You help developers and DBAs read Postgres query plans. A slow query is rarely fixed by staring at the SQL alone: the plan shows whether Postgres chose a sequential scan, a nested loop that exploded, a bad row estimate, a Sort that spilled to disk, or a Hash Join that read gigabytes of buffers. You take an `EXPLAIN` or `EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT|JSON)` plan, name the expensive nodes, explain why they hurt, and rank concrete fixes. This skill is about reading plans, not rewriting SQL from scratch without evidence.

You do not run destructive statements. Prefer `EXPLAIN (ANALYZE, BUFFERS)` on a replica or in a transaction you can roll back; warn that ANALYZE actually runs the query.

## Files in this skill

- `scripts/explain_analyze.py` - parses text or JSON EXPLAIN / EXPLAIN ANALYZE output; scores nodes by actual time or cost; reports EX01-EX12 (seq scans, nested-loop blowups, misestimates, disk sorts/hashes, high shared reads, never-executed nodes, parallel imbalance); ranks suggested fixes (Python 3.8+ standard library only)
- `references/plan-nodes-and-buffers.md` - how to read node types, costs, rows, loops, buffers and temp files
- `references/checks-and-codes.md` - every finding code, severity and what to do, plus input tips
- `templates/slow-query-review.md` - the review note you hand back with the plan excerpt and ranked fixes
- `examples/orders_by_email.explain.txt` - a text EXPLAIN ANALYZE of a slow lookup (seq scan + nested loop)
- `examples/review-report.md` - the real analyzer output on the example

## Workflow

### 1. Collect the plan
Ask for Postgres version, the SQL, and an `EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)` (or JSON). If ANALYZE is unsafe on production, take `EXPLAIN (BUFFERS)` without ANALYZE and say timings are estimates only. Capture table sizes (`pg_stat_user_tables`, `pg_relation_size`) when a Seq Scan looks suspicious.

### 2. Run the analyzer
```
python3 scripts/explain_analyze.py examples/orders_by_email.explain.txt
python3 scripts/explain_analyze.py plan.json --json-out
python3 scripts/explain_analyze.py plan.txt --min-ms 5
python3 scripts/explain_analyze.py --self-test
```
Exit 0 = no high findings, 1 = high findings, 2 = bad input. Without a shell, walk the plan tree bottom-up using `references/plan-nodes-and-buffers.md` and apply the codes in `references/checks-and-codes.md`.

### 3. Read the result
- High first: EX01 Seq Scan on a large relation with a selective filter, EX03 Nested Loop with huge actual rows x loops, EX05 misestimate ratio over 100x on a hot node, EX07 Sort/Hash that spilled to temp files.
- Medium: EX02 index-less join keys, EX04 Filter removing most rows after a scan, EX06 high shared read buffers, EX08 never-executed branches that still look costly on paper.
- Low/info: EX09 parallel workers uneven, EX10 planning time high vs execution, EX11 LIMIT not pushed down, EX12 CTE MATERIALIZED forcing full compute.

### 4. Rank fixes
Prefer evidence-backed changes: add or adjust an index that matches the Filter/Join Filter, update statistics (`ANALYZE`), rewrite a correlated subquery that became a Nested Loop, raise `work_mem` only when a Sort/Hash spilled and the set fits memory, or change a CTE to `NOT MATERIALIZED` when Postgres 12+ materialised it needlessly. Do not invent indexes without naming columns from the plan.

### 5. Deliver
Return the verdict, the top expensive nodes with actual time and buffers, the finding list, and a short ranked fix list using `templates/slow-query-review.md`.

## Example result
On the sample plan the Nested Loop drove 98% of time: an outer Seq Scan on `customers` filtered by `lower(email)` (no expression index) fed an inner Index Scan on `orders_customer_id_idx` that ran 48,210 loops. Estimated rows on the outer scan were 42 vs actual 48,210 (misestimate). Shared read buffers were high on `orders`. Ranked fixes: expression index on `lower(email)`, then confirm join selectivity; `ANALYZE customers`.

## Rules

- Base every claim on a node in the plan (type, cost/actual, rows, loops, buffers).
- Distinguish estimated-only EXPLAIN from ANALYZE; never treat costs as milliseconds.
- Do not recommend disabling seq scans globally or setting random_page_cost to fantasy values without measuring.
- Warn that EXPLAIN ANALYZE runs the query (triggers, writes, locks).
- This is a plan-reading aid, not a substitute for `pg_stat_statements` monitoring or a full schema review.
FILE:examples/orders_by_email.explain.txt
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------
Nested Loop  (cost=0.42..184220.50 rows=12 width=72) (actual time=0.085..912.440 rows=96 loops=1)
  Buffers: shared hit=1204 read=88440
  ->  Seq Scan on public.customers c  (cost=0.00..1850.00 rows=42 width=40) (actual time=0.040..88.200 rows=48210 loops=1)
        Filter: (lower((email)::text) = 'ada@example.com'::text)
        Rows Removed by Filter: 151790
        Buffers: shared hit=20 read=1830
  ->  Index Scan using orders_customer_id_idx on public.orders o  (cost=0.42..4.20 rows=8 width=40) (actual time=0.015..0.016 rows=0 loops=48210)
        Index Cond: (customer_id = c.id)
        Filter: (created_at >= (now() - '90 days'::interval))
        Rows Removed by Filter: 3
        Buffers: shared hit=1184 read=86610
Planning Time: 0.412 ms
Execution Time: 912.610 ms
FILE:examples/review-report.md
# Postgres EXPLAIN analysis

Source: `examples/orders_by_email.explain.txt`
Execution Time: 912.61 ms
Planning Time: 0.412 ms

**Verdict: NEEDS WORK** - 3 high finding(s).

## Top nodes by time (or cost)

| Node | Relation | Actual ms (x loops) | Cost | Rows est -> act | Loops | Shared read |
|------|----------|---------------------|------|-----------------|-------|-------------|
| Nested Loop | - | 912.4 | 184220 | 12 -> 96 | 1 | 88440 |
| Index Scan | public.orders | 771.4 | 4 | 8 -> 0 | 48210 | 86610 |
| Seq Scan | public.customers | 88.2 | 1850 | 42 -> 48210 | 1 | 1830 |

## Findings

| Sev | Code | Detail |
|-----|------|--------|
| high | EX01 | Seq Scan on public.customers with selective Filter ((lower((email)::text) = 'ada@example.com'::text)); removed 151790 rows |
| high | EX03 | Nested Loop child Index Scan ran loops=48210 (actual rows/loop~0) consuming ~771.4 ms |
| high | EX05 | Misestimate on Seq Scan public.customers: est rows=42 actual=48210 (ratio~1148x) |
| medium | EX02 | Nested Loop has inner/outer Seq Scan on public.customers |
| medium | EX06 | Nested Loop ? shared read=88440 blocks |
| medium | EX06 | Index Scan public.orders shared read=86610 blocks |

## Ranked fixes (suggested)

1. Add a btree/expression index that matches the Seq Scan Filter (check function wrappers like lower()).
2. Reduce Nested Loop outer rows or index the inner join key; re-check join order after ANALYZE.
3. Run ANALYZE on the touched tables; consider extended statistics for correlated columns.
4. Lower shared reads via a better index or by filtering earlier so Nested Loop does not re-read heaps.

FILE:references/checks-and-codes.md
# Checks and codes

| Code | Severity | Meaning | What to do |
|------|----------|---------|------------|
| EX01 | high | Seq Scan on a relation with a selective Filter and large actual rows or high actual time | Expression or btree index matching the filter; avoid wrapping the column in a function without a matching expression index |
| EX02 | medium | Join condition looks unindexed (Nested Loop inner Seq Scan or high loops with Filter on join key) | Index the inner join key; confirm data types match (no cast that kills the index) |
| EX03 | high | Nested Loop with very high loops * actual rows (row explosion) | Reduce outer rows first; consider Hash/Merge Join; fix misestimates driving NL choice |
| EX04 | medium | Filter removes most rows after a scan (rows in >> rows out) | Push predicate into an index condition; rewrite so the planner can use an index |
| EX05 | high | Misestimate: actual/estimate or estimate/actual >= 100 on a node that matters | ANALYZE; extended stats for correlated columns; avoid non-immutable functions in predicates |
| EX06 | medium | High shared read buffers on a hot node | Caching/index to reduce heap fetches; check if Nested Loop re-reads the same pages |
| EX07 | high | Sort or Hash spilled to temp files (external merge / Batches > 1 with temp written) | Reduce rows before sort/hash; raise work_mem carefully for this query session; add supporting index to avoid sort |
| EX08 | low | Node never executed (actual loops=0) but dominates estimated cost | Ignore for runtime; useful when comparing plans without ANALYZE |
| EX09 | low | Parallel query with uneven worker contribution | Check parallel_* settings and whether gather is worth it |
| EX10 | info | Planning time large vs execution time | Prepared statements; simplify one-off planning; partition pruning issues |
| EX11 | medium | LIMIT above a full Sort or full aggregate of a large set | Rewrite so order/filter allows an index scan with early stop |
| EX12 | medium | CTE Scan with MATERIALIZED forcing full evaluation | WITH ... AS NOT MATERIALIZED (PG12+) or inline the CTE |

## Input tips
- Text plans: paste the full tree; do not strip indentation.
- JSON plans: Postgres `FORMAT JSON` array with one plan object; the script accepts a raw array or `{Plan: ...}`.
- Optional header lines like `QUERY PLAN` or dashed rules are ignored.
FILE:references/plan-nodes-and-buffers.md
# Plan nodes and buffers

## Capturing a useful plan
```sql
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT) <query>;
-- or
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) <query>;
```
On production prefer a hot standby or wrap writes in a transaction you roll back. `ANALYZE` executes the statement.

## Reading a text node line
Example: `Seq Scan on public.customers  (cost=0.00..1550.00 rows=42 width=64) (actual time=0.020..85.110 rows=48210 loops=1)`
- Node type and relation
- `cost=startup..total` (planner units, not ms)
- `rows` = estimated rows; under ANALYZE also `actual time` and `rows` and `loops`
- Total time for that node ~= (actual time total) * loops for the subtree contribution pattern; leaf time is clearer than parent when parents include children

## Common node types
- **Seq Scan**: read the whole heap. Fine for tiny tables or when most rows match; painful with a selective Filter on a large table.
- **Index Scan / Index Only Scan / Bitmap Heap Scan**: good when selective. Bitmap Heap with many rechecks can still hurt.
- **Nested Loop**: for each outer row, run inner. Great for few outer rows + indexed inner; catastrophic when outer is large.
- **Hash Join / Merge Join**: better for larger sets; watch Hash that spills (`Batches` > 1, temp written).
- **Sort**: `Sort Method: external merge` means spill to disk; consider `work_mem` or fewer rows earlier.
- **Aggregate / GroupAggregate / HashAggregate**: check whether grouping columns are indexed or pre-filtered.
- **CTE Scan**: Postgres 12+ may MATERIALIZE CTEs; can block filter pushdown.
- **Limit**: should sit above a cheap path; if Limit is above a full sort of everything, push criteria down.

## Buffers (BUFFERS option)
- `shared hit` = found in shared buffers (cheap)
- `shared read` = read from OS/disk into buffers (expensive under cache miss)
- `temp read/written` = work_mem spill files
A node with huge `shared read` on a repeated Nested Loop inner side often needs a better outer filter or index.

## Row misestimates
Compare `rows` (estimate) to actual `rows`. Ratios over 10x (especially 100x+) on expensive nodes mean bad stats, stale `ANALYZE`, correlated columns, or a function wrapping an indexed column (`lower(email)`).

## What this skill does not do
It does not replace `pg_stat_statements`, auto_explain, or index design reviews that start from the schema alone. It starts from one plan and explains that plan.
FILE:scripts/explain_analyze.py
#!/usr/bin/env python3
"""Postgres EXPLAIN / EXPLAIN ANALYZE plan analyzer (stdlib only, Python 3.8+).

Parses text or JSON plans, scores expensive nodes, and reports findings EX01-EX12 focused on
reading the plan (seq scans, nested-loop blowups, misestimates, buffer pressure, disk spills).

Usage:
  explain_analyze.py PLAN.txt|.json [--min-ms N] [--json-out]
  explain_analyze.py --self-test
Exit: 0 = no high findings, 1 = high findings, 2 = bad input.
"""
import argparse, json, re, sys

SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}

NODE_RE = re.compile(
    r"^(?P<indent>\s*)(?:->\s*)?(?P<type>.+?)"
    r"(?=\s+using\s+|\s+on\s+|\s*\(cost=)"
    r"(?:\s+using\s+(?P<idx>[\w.]+))?"
    r"(?:\s+on\s+(?P<rel>[\w.]+))?"
    r"(?:\s+(?P<alias>\w+))?"
    r"\s*\(cost=(?P<cstart>[\d.]+)\.\.(?P<ctotal>[\d.]+)\s+rows=(?P<erows>[\d]+)\s+width=(?P<width>[\d]+)\)"
    r"(?:\s*\(actual time=(?P<astart>[\d.]+)\.\.(?P<atotal>[\d.]+)\s+rows=(?P<arows>[\d]+)\s+loops=(?P<loops>[\d]+)\))?"
)
BUF_RE = re.compile(
    r"Buffers:\s*(?:shared hit=(?P<shit>\d+))?"
    r"(?:\s*read=(?P<sread>\d+))?"
    r"(?:.*?temp read=(?P<tread>\d+))?"
    r"(?:.*?written=(?P<twrite>\d+))?",
    re.I,
)
FILTER_RE = re.compile(r"^\s+Filter:\s*(.+)$")
REMOVED_RE = re.compile(r"Rows Removed by Filter:\s*(\d+)")
SORT_RE = re.compile(r"Sort Method:\s*(.+)$", re.I)
HASH_BATCH_RE = re.compile(r"Batches:\s*(\d+)", re.I)
PLAN_TIME_RE = re.compile(r"Planning Time:\s*([\d.]+)\s*ms", re.I)
EXEC_TIME_RE = re.compile(r"Execution Time:\s*([\d.]+)\s*ms", re.I)


class InputError(Exception):
    pass


def parse_text(text):
    lines = text.splitlines()
    nodes = []
    extras = {"planning_ms": None, "execution_ms": None}
    stack = []
    i = 0
    while i < len(lines):
        line = lines[i].rstrip()
        if not line or set(line) <= set("-=") or line.strip() == "QUERY PLAN":
            i += 1
            continue
        m = PLAN_TIME_RE.search(line)
        if m:
            extras["planning_ms"] = float(m.group(1)); i += 1; continue
        m = EXEC_TIME_RE.search(line)
        if m:
            extras["execution_ms"] = float(m.group(1)); i += 1; continue
        m = NODE_RE.match(line)
        if m:
            raw_indent = re.match(r"^(\s*)", line).group(1)
            ind = len(raw_indent)
            node = {
                "type": m.group("type").strip(),
                "rel": m.group("rel"),
                "idx": m.group("idx"),
                "cost_start": float(m.group("cstart")),
                "cost_total": float(m.group("ctotal")),
                "est_rows": int(m.group("erows")),
                "width": int(m.group("width")),
                "act_start": float(m.group("astart")) if m.group("astart") else None,
                "act_total": float(m.group("atotal")) if m.group("atotal") else None,
                "act_rows": int(m.group("arows")) if m.group("arows") else None,
                "loops": int(m.group("loops")) if m.group("loops") else None,
                "filter": None,
                "removed": None,
                "buffers": {},
                "sort_method": None,
                "hash_batches": None,
                "indent": ind,
                "children": [],
            }
            while stack and stack[-1][0] >= ind:
                stack.pop()
            if stack:
                stack[-1][1]["children"].append(node)
            else:
                nodes.append(node)
            stack.append((ind, node))
            j = i + 1
            while j < len(lines):
                pl = lines[j]
                if not pl.strip():
                    break
                if NODE_RE.match(pl) or PLAN_TIME_RE.search(pl) or EXEC_TIME_RE.search(pl):
                    break
                if not pl.startswith(" ") and not pl.startswith("\t"):
                    break
                fm = FILTER_RE.match(pl)
                if fm:
                    node["filter"] = fm.group(1).strip()
                rm = REMOVED_RE.search(pl)
                if rm:
                    node["removed"] = int(rm.group(1))
                bm = BUF_RE.search(pl)
                if bm:
                    node["buffers"] = {k: int(bm.group(k)) for k in ("shit", "sread", "tread", "twrite") if bm.group(k)}
                sm = SORT_RE.search(pl)
                if sm:
                    node["sort_method"] = sm.group(1).strip()
                hb = HASH_BATCH_RE.search(pl)
                if hb:
                    node["hash_batches"] = int(hb.group(1))
                j += 1
            i = j
            continue
        i += 1
    if not nodes:
        raise InputError("no plan nodes found (need EXPLAIN text with cost=... rows=...)")
    return nodes, extras


def parse_json_plan(obj):
    if isinstance(obj, list):
        if not obj:
            raise InputError("empty JSON plan array")
        obj = obj[0]
    if not isinstance(obj, dict):
        raise InputError("JSON plan must be an object or array of objects")
    plan = obj.get("Plan") or obj.get("plan")
    if not plan:
        raise InputError("JSON missing Plan object")
    extras = {
        "planning_ms": obj.get("Planning Time"),
        "execution_ms": obj.get("Execution Time"),
    }

    def conv(p, indent=0):
        node = {
            "type": p.get("Node Type", "Unknown"),
            "rel": p.get("Relation Name") or p.get("Relation"),
            "idx": p.get("Index Name"),
            "cost_start": float(p.get("Startup Cost", 0)),
            "cost_total": float(p.get("Total Cost", 0)),
            "est_rows": int(p.get("Plan Rows", 0)),
            "width": int(p.get("Plan Width", 0)),
            "act_start": p.get("Actual Startup Time"),
            "act_total": p.get("Actual Total Time"),
            "act_rows": p.get("Actual Rows"),
            "loops": p.get("Actual Loops"),
            "filter": p.get("Filter"),
            "removed": p.get("Rows Removed by Filter"),
            "buffers": {},
            "sort_method": p.get("Sort Method"),
            "hash_batches": p.get("Hash Batches") or p.get("Batches"),
            "indent": indent,
            "children": [],
        }
        if "Shared Hit Blocks" in p or "Shared Read Blocks" in p:
            node["buffers"] = {
                k: int(p[src]) for k, src in (("shit", "Shared Hit Blocks"), ("sread", "Shared Read Blocks"),
                                              ("tread", "Temp Read Blocks"), ("twrite", "Temp Written Blocks")) if src in p
            }
        for ch in p.get("Plans") or []:
            node["children"].append(conv(ch, indent + 2))
        return node

    return [conv(plan)], extras


def walk(nodes):
    out = []
    def w(n):
        out.append(n)
        for c in n["children"]:
            w(c)
    for n in nodes:
        w(n)
    return out


def time_ms(n):
    if n["act_total"] is None or n["loops"] is None:
        return None
    return float(n["act_total"]) * float(n["loops"])


def analyze(nodes, extras, min_ms=1.0):
    findings = []
    flat = walk(nodes)

    def add(sev, code, msg, node=None):
        findings.append((sev, code, msg, node))

    for n in flat:
        t = n["type"]
        ms = time_ms(n)
        est, act = n["est_rows"], n["act_rows"]
        loops = n["loops"] or 1
        rel = n["rel"] or "?"

        if t.startswith("Seq Scan") and n.get("filter") and (n.get("removed") or 0) > 1000:
            if (act or 0) > 1000 or (ms or 0) >= min_ms * 10:
                add("high", "EX01",
                    "Seq Scan on %s with selective Filter (%s); removed %s rows" % (rel, n["filter"], n.get("removed")), n)

        if t.startswith("Nested Loop"):
            for c in n["children"]:
                if c["type"].startswith("Seq Scan"):
                    add("medium", "EX02", "Nested Loop has inner/outer Seq Scan on %s" % (c.get("rel") or "?"), c)

        if t.startswith("Nested Loop"):
            for c in n["children"]:
                cl = c.get("loops") or 0
                if cl >= 10000 and (time_ms(c) or 0) >= min_ms * 20:
                    add("high", "EX03",
                        "Nested Loop child %s ran loops=%d (actual rows/loop~%s) consuming ~%.1f ms" % (
                            c["type"], cl, c.get("act_rows"), time_ms(c) or 0), c)

        if n.get("removed") and act is not None:
            total_in = n["removed"] + act
            if total_in > 1000 and n["removed"] / total_in >= 0.9:
                add("medium", "EX04",
                    "Filter on %s %s removes %.0f%% of rows (%d removed, %d kept)" % (
                        t, rel, 100.0 * n["removed"] / total_in, n["removed"], act), n)

        if act is not None and est is not None and (ms or 0) >= min_ms:
            if est == 0:
                ratio = float("inf") if act > 0 else 1.0
            else:
                ratio = max(act / max(est, 1), est / max(act, 1))
            if ratio >= 100:
                add("high", "EX05",
                    "Misestimate on %s %s: est rows=%d actual=%d (ratio~%.0fx)" % (t, rel, est, act, ratio if ratio != float("inf") else 9999), n)

        sread = n.get("buffers", {}).get("sread") or 0
        if sread >= 5000 and (ms or 0) >= min_ms:
            add("medium", "EX06", "%s %s shared read=%d blocks" % (t, rel, sread), n)

        sm = (n.get("sort_method") or "").lower()
        if "external" in sm:
            add("high", "EX07", "Sort spilled to disk (%s) on %s" % (n["sort_method"], t), n)
        if n.get("hash_batches") and n["hash_batches"] > 1 and (n.get("buffers", {}).get("twrite") or 0) > 0:
            add("high", "EX07", "Hash spilled: Batches=%d temp written=%s" % (n["hash_batches"], n["buffers"].get("twrite")), n)

        if loops == 0 and n["cost_total"] >= 1000:
            add("low", "EX08", "Node %s never executed but cost=%.0f" % (t, n["cost_total"]), n)

        if t.startswith("Limit"):
            for c in n["children"]:
                if c["type"].startswith("Sort") and (c.get("act_rows") or c.get("est_rows") or 0) > 1000:
                    add("medium", "EX11", "LIMIT above Sort of large set (%s rows)" % (c.get("act_rows") or c.get("est_rows")), n)

        if "CTE" in t and (ms or n["cost_total"] > 1000):
            add("medium", "EX12", "CTE Scan %s may be materialized; consider NOT MATERIALIZED if filters should push down" % (n.get("rel") or ""), n)

    if extras.get("planning_ms") and extras.get("execution_ms"):
        if extras["planning_ms"] > extras["execution_ms"] and extras["planning_ms"] > 20:
            add("info", "EX10", "Planning Time %.1f ms > Execution Time %.1f ms" % (extras["planning_ms"], extras["execution_ms"]), None)

    findings.sort(key=lambda x: (SEV.get(x[0], 9), x[1]))
    return findings


def report(nodes, extras, findings, src):
    flat = walk(nodes)
    lines = []
    lines.append("# Postgres EXPLAIN analysis")
    lines.append("")
    lines.append("Source: `%s`" % src)
    if extras.get("execution_ms") is not None:
        lines.append("Execution Time: %s ms" % extras["execution_ms"])
    if extras.get("planning_ms") is not None:
        lines.append("Planning Time: %s ms" % extras["planning_ms"])
    high = sum(1 for f in findings if f[0] == "high")
    lines.append("")
    if high:
        lines.append("**Verdict: NEEDS WORK** - %d high finding(s)." % high)
    elif findings:
        lines.append("**Verdict: OK with notes** - %d finding(s), none high." % len(findings))
    else:
        lines.append("**Verdict: OK** - no findings above thresholds.")
    lines.append("")
    lines.append("## Top nodes by time (or cost)")
    lines.append("")
    scored = []
    for n in flat:
        ms = time_ms(n)
        scored.append((ms if ms is not None else -1, n["cost_total"], n))
    scored.sort(key=lambda x: (x[0] if x[0] >= 0 else -1, x[1]), reverse=True)
    lines.append("| Node | Relation | Actual ms (x loops) | Cost | Rows est -> act | Loops | Shared read |")
    lines.append("|------|----------|---------------------|------|-----------------|-------|-------------|")
    for ms, cost, n in scored[:12]:
        am = ("%.1f" % ms) if ms is not None and ms >= 0 else "-"
        ra = "%s -> %s" % (n["est_rows"], n["act_rows"] if n["act_rows"] is not None else "-")
        lines.append("| %s | %s | %s | %.0f | %s | %s | %s |" % (
            n["type"], n["rel"] or "-", am, cost, ra, n["loops"] if n["loops"] is not None else "-",
            n.get("buffers", {}).get("sread", "-")))
    lines.append("")
    lines.append("## Findings")
    lines.append("")
    if not findings:
        lines.append("None.")
    else:
        lines.append("| Sev | Code | Detail |")
        lines.append("|-----|------|--------|")
        for sev, code, msg, _ in findings:
            lines.append("| %s | %s | %s |" % (sev, code, msg.replace("|", "/")))
    lines.append("")
    lines.append("## Ranked fixes (suggested)")
    lines.append("")
    fixes = []
    codes = {f[1] for f in findings}
    if "EX01" in codes:
        fixes.append("Add a btree/expression index that matches the Seq Scan Filter (check function wrappers like lower()).")
    if "EX03" in codes or "EX02" in codes:
        fixes.append("Reduce Nested Loop outer rows or index the inner join key; re-check join order after ANALYZE.")
    if "EX05" in codes:
        fixes.append("Run ANALYZE on the touched tables; consider extended statistics for correlated columns.")
    if "EX07" in codes:
        fixes.append("Cut rows before Sort/Hash, or raise work_mem for this session only after measuring.")
    if "EX06" in codes:
        fixes.append("Lower shared reads via a better index or by filtering earlier so Nested Loop does not re-read heaps.")
    if "EX11" in codes:
        fixes.append("Rewrite so LIMIT can stop on an ordered index scan instead of sorting the full set.")
    if "EX12" in codes:
        fixes.append("Try WITH ... AS NOT MATERIALIZED or inline the CTE so filters push down.")
    if not fixes:
        fixes.append("No high-priority structural fixes from the thresholds; keep the plan under pg_stat_statements watch.")
    for i, f in enumerate(fixes, 1):
        lines.append("%d. %s" % (i, f))
    lines.append("")
    return "\n".join(lines) + "\n"


def load_plan(path):
    raw = open(path, encoding="utf-8").read()
    if path.endswith(".json") or raw.lstrip().startswith(("[", "{")):
        try:
            obj = json.loads(raw)
        except json.JSONDecodeError as e:
            raise InputError("invalid JSON: %s" % e)
        return parse_json_plan(obj)
    return parse_text(raw)


def self_test():
    tests = []
    def t(name, ok):
        tests.append((name, bool(ok)))

    sample = """Nested Loop  (cost=0.42..184220.50 rows=12 width=72) (actual time=0.085..912.440 rows=96 loops=1)
  Buffers: shared hit=1204 read=88440
  ->  Seq Scan on public.customers c  (cost=0.00..1850.00 rows=42 width=40) (actual time=0.040..88.200 rows=48210 loops=1)
        Filter: (lower((email)::text) = 'ada@example.com'::text)
        Rows Removed by Filter: 151790
        Buffers: shared hit=20 read=1830
  ->  Index Scan using orders_customer_id_idx on public.orders o  (cost=0.42..4.20 rows=8 width=40) (actual time=0.015..0.016 rows=0 loops=48210)
        Index Cond: (customer_id = c.id)
        Buffers: shared hit=1184 read=86610
Planning Time: 0.412 ms
Execution Time: 912.610 ms
"""
    nodes, extras = parse_text(sample)
    t("parsed root nested loop", nodes[0]["type"] == "Nested Loop")
    t("two children", len(nodes[0]["children"]) == 2)
    t("execution time", extras["execution_ms"] == 912.610)
    findings = analyze(nodes, extras)
    codes = {f[1] for f in findings}
    t("EX01 seq scan selective", "EX01" in codes)
    t("EX03 nested loop explosion", "EX03" in codes)
    t("EX05 misestimate", "EX05" in codes)
    t("EX06 shared reads", "EX06" in codes)
    r = report(nodes, extras, findings, "t")
    t("report verdict needs work", "NEEDS WORK" in r)

    tiny = """Seq Scan on public.t  (cost=0.00..1.05 rows=5 width=4) (actual time=0.002..0.003 rows=5 loops=1)
Execution Time: 0.020 ms
"""
    nodes2, ex2 = parse_text(tiny)
    f2 = analyze(nodes2, ex2)
    t("tiny table no high", not any(x[0] == "high" for x in f2))

    spill = """Sort  (cost=1000..1100 rows=10000 width=8) (actual time=50..60 rows=10000 loops=1)
  Sort Method: external merge  Disk: 4096kB
"""
    ns, es = parse_text(spill)
    fs = analyze(ns, es)
    t("EX07 external sort", any(x[1] == "EX07" for x in fs))

    j = [{"Plan": {"Node Type": "Limit", "Startup Cost": 0, "Total Cost": 10, "Plan Rows": 10, "Plan Width": 4,
                  "Actual Startup Time": 0.1, "Actual Total Time": 5.0, "Actual Rows": 10, "Actual Loops": 1,
                  "Plans": [{"Node Type": "Sort", "Startup Cost": 0, "Total Cost": 9, "Plan Rows": 5000, "Plan Width": 4,
                             "Actual Startup Time": 0.1, "Actual Total Time": 4.9, "Actual Rows": 5000, "Actual Loops": 1,
                             "Sort Method": "quicksort"}]},
          "Planning Time": 1.0, "Execution Time": 5.1}]
    nj, ej = parse_json_plan(j)
    fj = analyze(nj, ej)
    t("JSON limit+sort -> EX11", any(x[1] == "EX11" for x in fj))

    try:
        parse_text("not a plan")
        ok = False
    except InputError:
        ok = True
    t("bad input rejected", ok)

    for name, ok in tests:
        print(("PASS  " if ok else "FAIL  ") + name)
    passed = sum(ok for _, ok in tests)
    print("%d/%d passed" % (passed, len(tests)))
    return 0 if passed == len(tests) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Analyze Postgres EXPLAIN / EXPLAIN ANALYZE plans.")
    ap.add_argument("plan", nargs="?")
    ap.add_argument("--min-ms", type=float, default=1.0)
    ap.add_argument("--json-out", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.plan:
        ap.print_usage(sys.stderr)
        return 2
    try:
        nodes, extras = load_plan(a.plan)
        findings = analyze(nodes, extras, min_ms=a.min_ms)
    except (OSError, InputError) as e:
        print("error: %s" % e, file=sys.stderr)
        return 2
    if a.json_out:
        print(json.dumps({
            "execution_ms": extras.get("execution_ms"),
            "planning_ms": extras.get("planning_ms"),
            "findings": [{"severity": s, "code": c, "detail": m} for s, c, m, _ in findings],
        }, indent=2))
    else:
        sys.stdout.write(report(nodes, extras, findings, a.plan))
    return 1 if any(f[0] == "high" for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
FILE:templates/slow-query-review.md
# Slow query plan review

- Date:
- Database / Postgres version:
- Environment (prod replica / staging):
- Query name or ticket:

## SQL (short)
```sql

```

## Verdict
- Overall:
- Dominant time sinks:

## Top nodes
| Node | Actual time / cost | Rows est -> act | Loops | Buffers | Notes |
|------|--------------------|-----------------|-------|---------|-------|
|      |                    |                 |       |         |       |

## Findings
| Code | Severity | Detail |
|------|----------|--------|
|      |          |        |

## Ranked fixes
1.
2.
3.

## Follow-ups
- [ ] Capture `EXPLAIN (ANALYZE, BUFFERS)` after the change
- [ ] Check `pg_stat_user_indexes` / unused indexes before adding more
- [ ] Note whether ANALYZE on base tables was stale
PythonDatabasePerformance+2
F@f
0
Previous7 / 7