Sweep the code in scope for typing the checker lets through but shouldn't have
to: an Any standing in for a type you can actually name, a suppression
comment with no reason, an Any excused as "it's a boundary" when the value's
shape is known. Gradual typing rots exactly here — everything the linter
already fails on is someone else's job; this audit's job is what's left after
that.
The default deliverable is a report, not applied edits. The sweep writes
a machine-readable findings log and a grouped HTML summary; it touches no
code.
The one test
A finding fires only when a precise replacement type can be named, or a
suppression is missing its reason. Default is NO FINDING: the burden of
proof is on the finding. "This Any feels loose" with no nameable type
behind it is not a finding, no matter how untyped it looks — you must be able
to write the type you'd replace it with. "This ignore feels lazy" with a
reason comment already on it is not a finding either — a present reason is a
present reason, whether or not you'd have picked different words.
This is not a strictness pass. A repo choosing gradual typing on purpose is
not this audit's business — only the two provable failures above are.
Gate-red is not this audit's business — subtract it first
Anything ruff ANN or the project's type-checker already flags red is
excluded, unconditionally. Re-reporting a gate failure as "noise" this
audit found teaches the reader to distrust both tools. Before triage, run the
gate over the same scope and drop every line a hit already covers:
uvx ruff check --select ANN --output-format json <scope>
and whichever checker the repo actually uses. Check for its config first —
[tool.ty]/[tool.mypy] in pyproject.toml, a mypy.ini, or
pyrightconfig.json/[tool.pyright] — and run that one. No config found
anywhere: fall through ty → mypy → pyright and run the first that's
installed.
uvx ty check <scope> # ty
uvx mypy <scope> # mypy
uvx pyright <scope> # pyright
Parse each tool's file:line hits into a set and drop any grep candidate that
lands on one of those lines before triage starts. A repo with no type-checker
configured just means the gate output is empty — subtract nothing, triage
everything the grep found.
Grep candidates
Grep does not judge — it only narrows where to look. Two patterns:
Any usages — \bAny\b in annotations, TypeVar bounds, cast(Any, ...), dict[str, Any], etc. Import sites (from typing import Any) are
not usages; skip them.
- Ignore comments, every dialect:
# type: ignore / # type: ignore[code] (mypy/ty), # pyright: ignore / # pyright: ignore[code],
# mypy: ignore, # ty: ignore / # ty: ignore[code], and the
file-level # mypy: ignore-errors.
Triage into buckets & categories
For everything left after the gate subtraction, read the code around each
hit — not just the grep line, since knowability depends on how the value is
built and used — and sort into:
tighten / loose-any — the value's real shape is knowable from how
it's constructed or consumed (a function always returns a dict with
fixed keys typed Any; a param only ever receives str | int). Name the
precise type in both failure and extra.suggested_type. If you cannot
name one, this is not a finding — move on.
tighten / fake-boundary — an Any defended as "external boundary,
can't type it" (in a comment or by context — JSON parse result, API
response, env var) when the boundary's actual shape is already known and
typeable (the API's response schema is documented or used consistently
elsewhere in the file). Name the precise type same as loose-any. A
genuine boundary — truly dynamic, shape unknown at write time — is not a
finding; that's the rare keep.
justify / unexplained-ignore — a suppression with no reason. A bare
# type: ignore or # pyright: ignore[reportGeneralTypeIssues] with
nothing after it is unexplained; # type: ignore # upstream stub is wrong until v2 is not, regardless of how thin the reason reads. failure says
what's missing; extra.severity carries blanket (bare # type: ignore,
no error code) or narrowed (# type: ignore[assignment] — scoped to one
or more specific error codes). A blanket ignore is worse: it swallows every
future error on that line, not just the one it was written for.
keep — surfaced as context only, never a finding driver: a genuine
boundary Any or a suppression that already carries a reason. Do not add
keep rows to findings.jsonl — they're not findings. Mention the count
in the summary's metabar only.
The audit, worked
def parse_config(raw: Any) -> Any: # (1)
data = json.loads(raw)
return data["settings"] # (2)
result = risky_call() # type: ignore # (3)
value = external_api.fetch() # type: ignore[no-any-return] # boundary — SDK is untyped # (4)
raw: Any / return Any on parse_config — the param is only ever
called with str, and the return is always the "settings" sub-dict, a
dict[str, str] per every call site. loose-any, tighten,
suggested_type: "dict[str, str]".
- Not a separate finding — same function, already covered by (1)'s fix.
# type: ignore with nothing after it — unexplained-ignore,
justify, severity: "blanket" (no error code either).
- Has a reason ("boundary — SDK is untyped") and a scoped code — check
whether the boundary claim actually holds. If
external_api's SDK ships
no stubs and its response shape genuinely varies, this is a real
boundary: keep. If the SDK's return is documented and used the same way
at every call site in the file, the boundary excuse is fake: fake-
boundary, tighten, suggested_type named from the documented shape.
Run
Scope. Audit $ARGUMENTS if given; with no argument, scope defaults
per ~/.agents/skills/all-audits/SKILL.md's Scope section. Skip vendored, generated, and dependency trees
(node_modules, dist, .venv, build output, lockfiles) and any
worktrees/ tree. Python only — this audit has nothing to say about a
non-Python file.
Run the gate and subtract. uvx ruff check --select ANN --output-format json <scope> plus the repo's type-checker (ty/mypy/
pyright — use whichever the repo already has configured; fall back
through the list above if none is declared). Collect every file:line
either tool flags. Any grep candidate on one of those lines is dropped
before triage — it is the gate's finding, not this audit's.
Grep candidates, both patterns above, over the surviving scope.
Triage every remaining candidate — not a sample — into loose-any /
unexplained-ignore / fake-boundary / keep, reading the surrounding code
each time. Apply the one test before recording anything: named type or
missing reason, or it doesn't go in the log.
Write the findings log and render the summary — the default
deliverable. keep rows are counted, not logged. See
~/.agents/skills/all-audits/harness/AUDIT-RUN.md for the shared
write-and-deliver step (tmpdir resolution, findings.jsonl +
report.html, opening, and the final print). This skill's own bucket
names and metabar:
Write the log and render the summary
- Log — one JSONL line per
tighten/justify finding, the six required
fields plus extra: file/line of the Any or ignore comment,
summary (one line, what's loose), bucket (tighten / justify),
category (loose-any / unexplained-ignore / fake-boundary), and
failure naming the concrete cost ("caller passes a list[int] here and
gets no error until it hits the missing .items() three functions away").
extra.suggested_type on tighten findings from loose-any/fake- boundary; extra.severity (blanket/narrowed) on justify findings.
- Summary — the verdict, the
N findings · T tighten · J justify · K kept metabar (kept from the count only, no rows), findings grouped by
bucket then category with counts, and a vt-callout naming the highest-
value finds by file:line. No per-finding cards. Note the gate command run
and how many candidates it removed, so a reader can see the subtraction
happened.
1---2name: type-tightness3description: Audit loose typing the type-checker still accepts — Any where a real type is knowable, ignore-comments with no reason, and fake "boundary" excuses.4---56Sweep the code in scope for typing the checker lets through but shouldn't have7to: an `Any` standing in for a type you can actually name, a suppression8comment with no reason, an `Any` excused as "it's a boundary" when the value's9shape is known. Gradual typing rots exactly here — everything the linter10already fails on is someone else's job; this audit's job is what's left after11that.1213The default deliverable is a **report**, not applied edits. The sweep writes14a machine-readable findings log and a grouped HTML summary; it touches no15code.1617## The one test1819**A finding fires only when a precise replacement type can be named, or a20suppression is missing its reason.** Default is NO FINDING: the burden of21proof is on the finding. "This `Any` feels loose" with no nameable type22behind it is not a finding, no matter how untyped it looks — you must be able23to write the type you'd replace it with. "This ignore feels lazy" with a24reason comment already on it is not a finding either — a present reason is a25present reason, whether or not you'd have picked different words.2627This is not a strictness pass. A repo choosing gradual typing on purpose is28not this audit's business — only the two provable failures above are.2930## Gate-red is not this audit's business — subtract it first3132Anything ruff `ANN` or the project's type-checker already flags red is33**excluded**, unconditionally. Re-reporting a gate failure as "noise" this34audit found teaches the reader to distrust both tools. Before triage, run the35gate over the same scope and drop every line a hit already covers:3637```sh38uvx ruff check --select ANN --output-format json <scope>39```4041and whichever checker the repo actually uses. Check for its config first —42`[tool.ty]`/`[tool.mypy]` in `pyproject.toml`, a `mypy.ini`, or43`pyrightconfig.json`/`[tool.pyright]` — and run that one. No config found44anywhere: fall through `ty` → `mypy` → `pyright` and run the first that's45installed.4647```sh48uvx ty check <scope> # ty49uvx mypy <scope> # mypy50uvx pyright <scope> # pyright51```5253Parse each tool's file:line hits into a set and drop any grep candidate that54lands on one of those lines before triage starts. A repo with no type-checker55configured just means the gate output is empty — subtract nothing, triage56everything the grep found.5758## Grep candidates5960Grep does not judge — it only narrows where to look. Two patterns:6162- **`Any` usages** — `\bAny\b` in annotations, `TypeVar` bounds, `cast(Any,63 ...)`, `dict[str, Any]`, etc. Import sites (`from typing import Any`) are64 not usages; skip them.65- **Ignore comments**, every dialect: `# type: ignore` / `# type:66 ignore[code]` (mypy/ty), `# pyright: ignore` / `# pyright: ignore[code]`,67 `# mypy: ignore`, `# ty: ignore` / `# ty: ignore[code]`, and the68 file-level `# mypy: ignore-errors`.6970## Triage into buckets & categories7172For everything left after the gate subtraction, read the code around each73hit — not just the grep line, since knowability depends on how the value is74built and used — and sort into:7576- **`tighten`** / `loose-any` — the value's real shape is knowable from how77 it's constructed or consumed (a function always returns a `dict` with78 fixed keys typed `Any`; a param only ever receives `str | int`). Name the79 precise type in both `failure` and `extra.suggested_type`. If you cannot80 name one, this is not a finding — move on.81- **`tighten`** / `fake-boundary` — an `Any` defended as "external boundary,82 can't type it" (in a comment or by context — JSON parse result, API83 response, env var) when the boundary's actual shape is already known and84 typeable (the API's response schema is documented or used consistently85 elsewhere in the file). Name the precise type same as `loose-any`. A86 genuine boundary — truly dynamic, shape unknown at write time — is not a87 finding; that's the rare `keep`.88- **`justify`** / `unexplained-ignore` — a suppression with no reason. A bare89 `# type: ignore` or `# pyright: ignore[reportGeneralTypeIssues]` with90 nothing after it is unexplained; `# type: ignore # upstream stub is wrong91 until v2` is not, regardless of how thin the reason reads. `failure` says92 what's missing; `extra.severity` carries `blanket` (bare `# type: ignore`,93 no error code) or `narrowed` (`# type: ignore[assignment]` — scoped to one94 or more specific error codes). A blanket ignore is worse: it swallows every95 future error on that line, not just the one it was written for.96- **`keep`** — surfaced as context only, never a finding driver: a genuine97 boundary `Any` or a suppression that already carries a reason. Do not add98 `keep` rows to `findings.jsonl` — they're not findings. Mention the count99 in the summary's metabar only.100101## The audit, worked102103```python104def parse_config(raw: Any) -> Any: # (1)105 data = json.loads(raw)106 return data["settings"] # (2)107108result = risky_call() # type: ignore # (3)109110value = external_api.fetch() # type: ignore[no-any-return] # boundary — SDK is untyped # (4)111```1121131. `raw: Any` / return `Any` on `parse_config` — the param is only ever114 called with `str`, and the return is always the `"settings"` sub-dict, a115 `dict[str, str]` per every call site. **loose-any**, `tighten`,116 `suggested_type: "dict[str, str]"`.1172. Not a separate finding — same function, already covered by (1)'s fix.1183. `# type: ignore` with nothing after it — **unexplained-ignore**,119 `justify`, `severity: "blanket"` (no error code either).1204. Has a reason ("boundary — SDK is untyped") and a scoped code — check121 whether the boundary claim actually holds. If `external_api`'s SDK ships122 no stubs and its response shape genuinely varies, this is a real123 boundary: `keep`. If the SDK's return is documented and used the same way124 at every call site in the file, the boundary excuse is fake: **fake-125 boundary**, `tighten`, `suggested_type` named from the documented shape.126127## Run1281291. **Scope.** Audit `$ARGUMENTS` if given; with no argument, scope defaults130 per `~/.agents/skills/all-audits/SKILL.md`'s Scope section. Skip vendored, generated, and dependency trees131 (`node_modules`, `dist`, `.venv`, build output, lockfiles) and any132 `worktrees/` tree. Python only — this audit has nothing to say about a133 non-Python file.1341352. **Run the gate and subtract.** `uvx ruff check --select ANN136 --output-format json <scope>` plus the repo's type-checker (`ty`/mypy/137 pyright — use whichever the repo already has configured; fall back138 through the list above if none is declared). Collect every `file:line`139 either tool flags. Any grep candidate on one of those lines is dropped140 before triage — it is the gate's finding, not this audit's.1411423. **Grep candidates**, both patterns above, over the surviving scope.1431444. **Triage every remaining candidate** — not a sample — into loose-any /145 unexplained-ignore / fake-boundary / keep, reading the surrounding code146 each time. Apply the one test before recording anything: named type or147 missing reason, or it doesn't go in the log.1481495. **Write the findings log and render the summary — the default150 deliverable.** `keep` rows are counted, not logged. See151 `~/.agents/skills/all-audits/harness/AUDIT-RUN.md` for the shared152 write-and-deliver step (tmpdir resolution, `findings.jsonl` +153 `report.html`, opening, and the final print). This skill's own bucket154 names and metabar:155156## Write the log and render the summary157158- **Log** — one JSONL line per `tighten`/`justify` finding, the six required159 fields plus `extra`: `file`/`line` of the `Any` or ignore comment,160 `summary` (one line, what's loose), `bucket` (`tighten` / `justify`),161 `category` (`loose-any` / `unexplained-ignore` / `fake-boundary`), and162 `failure` naming the concrete cost ("caller passes a `list[int]` here and163 gets no error until it hits the missing `.items()` three functions away").164 `extra.suggested_type` on `tighten` findings from `loose-any`/`fake-165 boundary`; `extra.severity` (`blanket`/`narrowed`) on `justify` findings.166- **Summary** — the verdict, the `N findings · T tighten · J justify · K167 kept` metabar (`kept` from the count only, no rows), findings grouped by168 bucket then category with counts, and a `vt-callout` naming the highest-169 value finds by `file:line`. No per-finding cards. Note the gate command run170 and how many candidates it removed, so a reader can see the subtraction171 happened.