Metadata-Version: 2.4
Name: qual-manim
Version: 0.3.0
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Rust
Classifier: Topic :: Software Development :: Quality Assurance
License-File: LICENSE
License-File: LICENSES/GPL-3.0-only.txt
License-File: LICENSES/LGPL-3.0-only.txt
License-File: RELINKING.md
License-File: THIRD-PARTY-LICENSES.md
Summary: Static lifecycle and performance analysis for Manim scenes
Keywords: manim,linter,static-analysis,animation,python
Home-Page: https://github.com/Poietra/qual
License-Expression: MIT
Requires-Python: >=3.8
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Changelog, https://github.com/Poietra/qual/blob/main/CHANGELOG.md
Project-URL: Homepage, https://github.com/Poietra/qual
Project-URL: Repository, https://github.com/Poietra/qual

# Qual

**Static analysis for [Manim Community](https://www.manim.community/) scenes — catch definite runtime errors, silent mis-rendering, performance multipliers, and non-determinism before you render.**

English | [日本語](README.ja.md)

`qual` is a standalone static analyzer for Manim Community **0.20**
projects, written in Rust. It parses your Python source and checks it against
a curated, versioned model of Manim's semantics — it **never imports or
executes** Manim or your code. Instead of pattern-matching API names, it runs
a lifecycle abstract interpreter that models what `Scene.play` actually does
(argument compilation, auto-add, introducers/removers, updaters) and a
symbolic cost model that knows which code runs once and which code runs every
frame.

## Example

`scenes/demo.py`:

```python
from manim import *


class TrackerDemo(Scene):
    def construct(self):
        title = Text("Tracking x", font_size=0)
        square = Square()
        tracker = ValueTracker(0)
        label = always_redraw(lambda: MathTex(f"x={tracker.get_value():.2f}"))
        self.add(title, square, label)
        self.play(square.shift(RIGHT))
        square.add_updater(lambda m: m.rotate(0.05))
        self.play(tracker.animate.set_value(8), run_time=8)
        self.wait(0)
```

```console
$ qual check . --format concise
scenes/demo.py:6:46: MLR115 error `Text(font_size=0)` is not positive; text sizing requires font_size > 0
scenes/demo.py:9:39: MLP226 warning Each invocation constructs a `MathTex` and performs a cache-key lookup, and this f-string key varies per frame: every rendered frame can mint a distinct Text/TeX cache key and disk asset (`K_resource ≈ F`). Across the 1 play(s) where this callback provably executes it may create at least ~480 distinct keys.
scenes/demo.py:11:19: MLC102 error `square.shift(...)` mutates the mobject immediately and returns the mobject itself, not an Animation; use `.animate` (e.g. `square.animate.shift(...)`) inside `Scene.play()`.
scenes/demo.py:12:38: MLD301 warning Updater lambda applies `rotate` with a fixed step every frame but declares no `dt` parameter; the motion speed depends on the profile frame rate
scenes/demo.py:14:9: MLC112 warning This `wait()` renders a single frozen frame: nothing makes it dynamic, and the updater registered at line 9 reads frame-varying state without a `dt` parameter, so its visual change never renders during the wait. Pass `frozen_frame=False`, or declare a `dt` parameter on the updater.
scenes/demo.py:14:19: MLC104 error Use a positive `duration`: the literal `0` is non-positive and playing it aborts the render.
```

Two of these would crash the render (`MLC102`, `MLC104`), one renders an
invisible title (`MLR115`), one silently changes speed with the frame rate
(`MLD301`), one freezes a wait that the author expected to animate
(`MLC112`), and one launches the external TeX compiler for a fresh cache
key on every rendered frame (`MLP226`) — and the linter can bound that
cost: `run_time=8` at 60 FPS is provably at least ~480 distinct keys.

## What it checks

Rules come in four families:

- **MLC — lifecycle / correctness.** Definite runtime errors and lifecycle
  mistakes that render the wrong picture: non-Animation arguments to
  `Scene.play` (`MLC102`), `MoveToTarget` without `generate_target()` on any
  path (`MLC107`), `Restore` without `save_state()` (`MLC120`), two
  animations writing the same channel of the same mobject in one play
  (`MLC108`), a `Scene.remove(child)` undone by re-adding the surviving
  parent (`MLC115`).
- **MLR — rendering.** Code that renders, but not what you meant: a Python
  escape corrupting a TeX command in a non-raw `MathTex` literal (`MLR103`),
  asset paths that fail Manim's exact runtime search (`MLR104`), Pango markup
  passed to plain `Text` (`MLR124`), `Transform(mob, mob)` (`MLR113`).
- **MLP — performance.** Cost multipliers with machine-readable evidence:
  `Text`/`MathTex`/`SVGMobject` construction inside an updater or
  `always_redraw` (`MLP201`), frame-varying TeX cache keys that mint one disk
  asset per frame (`MLP226`), scene graphs growing every frame (`MLP204`),
  `TracedPath` without `dissipating_time` (`MLP220`).
- **MLD — determinism / portability.** Renders that differ between machines,
  frame rates, or renderers: fixed per-frame steps without `dt` scaling
  (`MLD301`), unseeded global randomness in frame callbacks (`MLD302`),
  case-only asset path mismatches on case-sensitive targets (`MLD305`).

### Semantic depth, not name matching

The analyzer's core principle (DESIGN §1): never warn on an API name alone.
`FadeOut(mob)` is fine on a mobject that was never added — play's preparation
auto-adds it and the remover deletes it afterwards. So the pipeline builds
real facts first:

- a **lifecycle abstract interpreter**: intra-function CFG, interprocedural
  helper summaries, per-Scene MRO composition with `super()` dispatch,
  allocation-site identity, scene membership/order/updater tracking, and
  play-group semantics;
- a **symbolic cost model**: hot-context propagation (updaters,
  `always_redraw`, stop conditions, interpolate overrides) and frame-count
  intervals derived only from literal durations — the cost report above says
  `duration 8 s -> frames ~480` because `run_time=8` at 60 FPS is provable,
  and prints `unknown` otherwise, never a fabricated number.

Every diagnostic separates **severity** (`error`/`warning`/`info`) from
**confidence** (`certain`/`high`/`medium`/`low`), and state-dependent rules
fire only on definite, all-paths evidence. When a value cannot be resolved
statically, it degrades to `Unknown` and the linter stays **silent rather
than guessing** — a deliberate design stance carried through every rule.

## Installation

For a published release, choose any of these entry points. The PyPI package
installs the native Rust executable; it does not import Manim or require a
Python runtime after installation.

```bash
# Python tooling
uv tool install qual-manim
# or: pipx install qual-manim

# Rust tooling (builds from source; Rust 1.85+)
cargo install qual --locked
```

Standalone installers and checksummed archives for Linux, macOS, and Windows
are attached to each GitHub Release:

```bash
# macOS / Linux
curl --proto '=https' --tlsv1.2 -LsSf \
  https://github.com/Poietra/qual/releases/latest/download/qual-installer.sh | sh
```

Until the first registry release, or to install the current checkout, build
from source:

```bash
git clone https://github.com/Poietra/qual.git
cd qual
cargo install --path .
```

No Python, Manim, or LaTeX installation is needed: the analyzer parses
source and consults versioned knowledge profiles, and never imports or
executes Manim or the code it analyzes.

## Quickstart

```bash
qual check .                      # analyze; rich in a terminal, concise when piped
qual check . --format rich        # force source frames and colour
qual check . --format concise     # one line per diagnostic
qual check scenes --format full   # explanations + evidence
qual check . --format json        # schemas/diagnostics-v1.json
qual check . --format sarif       # SARIF 2.1.0
qual check . --format github      # GitHub Actions annotations
qual explain MLC102               # full documentation for a rule
qual rules                        # every rule ID, phase, and status
qual config                       # resolved effective configuration
qual cost scenes/demo.py          # per-scene cost breakdown
qual coverage .                   # what the analysis could not resolve
qual static-facts . > facts.json  # StaticFacts v0 semantic projection
qual change-impact --before old --after new > impact.json
qual source-bridge . --request patch.json > candidates.json
```

Exit codes: `0` — no reported diagnostic reaches `fail-level`; `1` — at
least one does; `2` — command-line, configuration, or internal error.

### Output formats

Without `--format`, `check` picks its output from where it is writing.
Attached to a terminal it prints `rich`: a banner per finding, the offending
source line with the span underlined, the explanation, and a summary.

```text
✖ MLC104 scene.py:10:42 ───────────────────────────────────────────────

  Use a positive `run_time`: the literal `0` is non-positive and playing
  it aborts the render.

     8 │         group = AnimationGroup()
     9 │         self.add(title, eq)
  > 10 │         self.play(Write(title), run_time=0)
       │                                          ^
    11 │         self.wait()

  ℹ Manim validates durations when a play executes, not when an animation
    is constructed …

✖ 2 errors  ⚠ 1 warning  in 1 file
```

Redirected to a file or a pipe it prints `concise` — one stable line per
diagnostic, with no escape sequences — so scripts and CI keep the format
they parse today. Pass `--format` to override the choice in either
direction.

Colour follows `--color auto|always|never`. `auto` styles only a terminal,
`NO_COLOR` (any value) disables styling, and `--color always` styles even
when redirected. Only `rich` is ever styled. `COLUMNS` sets the width the
banners and wrapping use.

Useful `check` options: `--select` / `--ignore`, `--min-confidence`,
`--fail-level`, `--profile`, `--renderer`, `--fps`,
`--resolution WIDTHxHEIGHT`, `--color`, `--statistics`, `--analysis-summary` (the
coverage report below, printed to stderr after the diagnostics; stdout
and the exit code are untouched), and the baseline/fix options
described below. `--no-cache` forces a full analysis without reading,
writing, or creating cache state. `--select` also narrows the analysis itself: fact layers
no selected rule needs (the lifecycle interpreter, the symbolic cost
model) are skipped, so a narrow select is faster than a full run. The
reported diagnostics are identical either way — rules superseding a
selected rule still run so a narrow select never resurrects a superseded
diagnostic.

`--format full` prints the explanation and machine-readable evidence under
each diagnostic:

```text
scenes/demo.py:9:39: MLP226 warning Each invocation constructs a `MathTex` and performs a cache-key lookup, ...
    A frame-varying key defeats the `MathTex` cache: instead of one shaping/compile job reused every frame,
    each frame pays construction plus a cache miss, and for TeX classes each distinct key also launches the
    external TeX compiler and `dvisvgm`, leaving one disk asset per key. ...
    evidence.distinct_resource_keys: {"lower":480,"upper":null}
    evidence.execution: {"plays":[{"certainty":"proven","kind":"play","location":"scenes/demo.py:13:9"},{"certainty":"maybe","kind":"play","location":"scenes/demo.py:11:9"}],"unresolved_entries":false}
    evidence.frames: {"lower":480,"upper":null}
    evidence.invocation_context: "frame-callback"
    evidence.multiplicity: ["frames"]
    evidence.state_path: ["construct","always_redraw:9"]
    applies to profiles: production
```

## Analysis cache

Normal `check` runs keep a disposable SQLite cache at
`.qual-cache/cache-v2.sqlite3`. An identical second run validates
filesystem dependencies and reuses the whole-project diagnostic JSON without
starting the frontend. After a source edit, cache v2 still parses and indexes
the complete project, then divides project files into weak dependency
components using resolved imports, calls, base classes, and module-name
collisions. Unchanged components reuse JSON method summaries and filtered
diagnostics; only changed components rerun summaries, Scene lifecycle, and
cost analysis. ASTs and analyzed code are never serialized or executed.

Keys cover the analyzer build, resolved semantic configuration, Manim
knowledge profile, complete source layout, and relevant source bytes. Literal
asset candidates and case-sensitive directory walks are stamped per entry,
so asset changes invalidate the affected component. The SQLite WAL database
supports concurrent cold writers and retains the 16 most recently used
project snapshots plus 256 component snapshots. It is never required for
correctness: corruption rebuilds with a warning and other failures continue
with analysis. `--no-cache` disables all cache filesystem activity. `--fix`,
baselines, and `--analysis-summary` deliberately run a complete analysis
because they need live source or index state after diagnostics are produced.
Add `.qual-cache/` to a project's ignore file. Older
`cache-v1.sqlite3` files are unused and may be removed.

Cold runs parallelize independent summary components, Scene lifecycle runs,
and rules using a bounded worker pool. Recursive summary fixpoints and the
frontend/project index remain ordered and sequential. Output is collected and
stably sorted, with a test proving byte-identical JSON at one and four workers.

## Configuration

Configuration lives in `[tool.qual]` in `pyproject.toml`, found by
walking up from the checked path. Render profiles are
`[[tool.qual.profile]]` entries:

```toml
[tool.qual]
manim-version = "0.20"
target-python = "3.11"
select = ["MLC", "MLR", "MLP", "MLD"]
ignore = []
min-confidence = "high"
fail-level = "warning"
default-profile = "production"
knowledge-profile = "upstream_0_20"
respect-manim-cfg = true
exclude = [".venv/**", "media/**"]
per-file-ignores = { "tests/fixtures/**" = ["MLP", "MLD"] }

[[tool.qual.profile]]
name = "production"
renderer = "cairo"
platform = "linux"
pixel-width = 1920
pixel-height = 1080
frame-rate = 60
assets-dir = "."
allowed-fonts = ["Noto Sans", "Noto Sans CJK JP"]
```

Precedence, highest first:

```text
CLI > selected profile > pyproject base > manim.cfg > builtin defaults
```

When `respect-manim-cfg` is enabled (the default), a `manim.cfg` supplies
resolution/fps/renderer defaults below the pyproject settings. Unknown keys,
unknown rule selectors, duplicate profile names, and unknown profile
references are configuration errors (exit 2). `--profile all` analyzes every
defined profile and merges same-evidence diagnostics, listing the affected
profiles per diagnostic.

Configuration is validated honestly (exit 2 on violation):

- A declared `manim-version` must fall inside the Manim range supported by
  the configured knowledge profile (e.g. `upstream_0_20` supports
  `>=0.20,<0.21`); when absent, nothing is validated.
- `target-python` must be `MAJOR.MINOR` between 3.6 and 3.12. The upper
  bound is the Python grammar the bundled parser (rustpython-parser 0.4)
  implements; the lower bound is the floor below which syntax gating can
  no longer be guaranteed (older targets are refused with exit 2 instead
  of being silently unenforced). The grammar is fixed (no
  `feature_version` pinning), so parsing itself never changes; instead a
  post-parse gate over the AST, the token stream, and f-string text
  reports every construct newer than the target as `MLC000`:
  `async`/`await` syntax outside `async def` (3.7), `:=`, positional-only
  `/`, and f-string self-documenting `=` (3.8), relaxed decorators and
  parenthesized context managers with `as` (3.9), `match` (3.10),
  `except*` and PEP 646 `*` unpacking in subscripts (3.11), `type`
  aliases, PEP 695 type parameters, and PEP 701 f-string expressions
  (3.12). A file the gate passes silently is guaranteed parseable by the
  target's own parser. The gated file is still fully analyzed, and a
  `--fix` that would introduce such syntax is rolled back. See
  `qual explain MLC000` for the full coverage table.
- A frame rate that is zero, negative, or non-finite, and a resolution
  with a zero dimension, are rejected wherever they come from (`--fps` /
  `--resolution`, a profile, or `manim.cfg`).
- `stub-paths` is not implemented yet; a non-empty list is rejected
  instead of being silently ignored.

`qual config` prints the resolved configuration plus an
`enforcement` section stating which settings are enforced and which are
informational.

## Using the optimized fork profile

Projects rendering with the locally patched Manim fork (profile
`local_0_20_1_4d25c031`) can tell qual so and unlock the
fork-specific analysis layer:

```toml
[tool.qual]
knowledge-profile = "local_0_20_1_4d25c031"
default-profile = "production"

[[tool.qual.profile]]
name = "production"
renderer = "cairo"
platform = "linux"
cairo-fork-workers = 4
cairo-static-layers = true
```

This enables, on top of everything the upstream profile provides:

- **A "fork fast paths" section in `qual cost`**: per play, whether
  the fork-per-play Cairo pipeline (`cairo-fork-workers`), the static-layer
  retention path (`cairo-static-layers`), and packed interpolation apply —
  with the exact blocker and its source span when they do not (e.g. a Scene
  updater), including the renderer-wide monotonic disable chain after the
  first serial play. The section never advises removing a feature; it
  explains the render-path consequence.
- **`MLP214`**: flags four or more distinct TeX compile keys constructed
  serially before a scene's first play and cites the fork's precompile
  APIs (`MathTex.precompile`, `tex_to_svg_file_async`).
- **`MLP217`**: flags frame-varying `use_svg_cache=True` keys in hot
  callbacks that grow the fork's declared process-global SVG cache every
  frame.
- **`MLP225`** (opt-in via `--select MLP225`): emits the cost report's
  fast-path blocker explanations as per-play diagnostics.

Under `upstream_0_20` all of the above is inert: the cost report carries no
fork section and the three rules never fire, even when selected.

## Suppressions

```python
self.play(square.shift(RIGHT))  # qual: ignore[MLC102]   # same statement

# qual: ignore[MLP201]                                   # next statement
label = always_redraw(...)

# qual: file-ignore[MLP]   # whole file; must appear in the file header
```

Suppressions target **whole statements**, not single lines: an end-of-line
comment (or a standalone comment directly above) covers the entire
statement, including continuation lines of a multi-line call, so a
diagnostic anchored anywhere inside the statement is suppressed. For
compound statements (`def`, `for`, `if`, `with`, ...) the suppression
covers only the header up to its colon — one comment can never silence an
entire suite.

An unknown rule ID inside an inline suppression does **not** suppress
anything; it is reported as a dedicated warning:

```text
scene.py:8:41: MLC001 warning unknown rule ID in suppression: MLC999
```

For whole directories, use `per-file-ignores` in `pyproject.toml` (see
above).

## Gradual adoption: baselines

Adopt the linter on an existing project without fixing everything first:

```bash
qual check . --write-baseline .qual-baseline.json  # record today's findings
qual check . --baseline .qual-baseline.json        # report only new findings
```

Baseline fingerprints (`schemas/baseline-v1.json`) contain **no line
numbers** — they are built from rule ID, relative path, qualified scene name,
and a surrounding token hash — so inserting unrelated lines elsewhere in a
file does not invalidate entries. The `scene` field records the qualified
enclosing Scene class (empty outside any scene), so identical findings in
different scenes get distinct fingerprints. Written files carry a
`scene_attribution: "attributed"` provenance marker: their empty `scene`
means literally "outside any Scene" and matches exactly. Baselines written
before scene attribution (no marker) are still read, and only there an
empty `scene` matches as a wildcard. A corrupt or wrong-schema baseline
file exits 2 with a clear message.

## Autofix

```bash
qual check . --fix            # apply SAFE fixes only
qual check . --fix --unsafe-fixes  # also apply UNSAFE fixes
```

Safe and unsafe fixes are strictly separated: `--fix` alone applies only
edits that preserve behavior (e.g. `MLC127` removes a duplicate child from
one `add()`/`VGroup()` call, `MLR104` corrects a case-only asset path).
Unsafe fixes can change runtime semantics (e.g. rewriting
`play(mob.shift(...))` to `play(mob.animate.shift(...))` for `MLC102`) and
require the explicit extra flag. Every fixed file is re-parsed for
validation; a file whose fix does not survive re-parsing is rolled back.

```console
$ qual check . --fix
scene.py:8:40: MLC127 info Remove the duplicate `square` from this `VGroup(...)` call: Manim warns and ignores repeated children of a single add.
fixed 1 issue(s) in 1 file(s)
```

## Cost command

`qual cost` prints the symbolic cost breakdown per scene — play list
with frame intervals, hot contexts with provenance and the plays where the
callback provably executes, per-frame constructions, and resource-key
growth. Unknown durations are printed as unknown, never as fabricated
numbers:

```console
$ qual cost scenes/demo.py
profiles: production (cairo, 1920x1080, 60 fps)

scene scenes.demo.TrackerDemo (scenes/demo.py)
  plays:
    scenes/demo.py:11:9 play duration unknown -> frames per-frame
    scenes/demo.py:13:9 play duration 8 s -> frames ~480
    scenes/demo.py:14:9 wait duration 0 s -> frames ~0
  hot contexts:
    scenes/demo.py:9:31 entry always_redraw; path construct -> always_redraw:9; factors frames; proven execution plays: scenes/demo.py:13:9
    scenes/demo.py:12:28 entry updater; path construct -> updater:12; factors frames; proven execution plays: scenes/demo.py:13:9
  per-frame constructions:
    scenes/demo.py:9:39 MathTex construction x at least ~480 invocations across 1 proven play(s)
  resource-key growth:
    scenes/demo.py:9:39 MathTex distinct cache keys: at least ~480 across 1 proven play(s) (f-string key varies per frame)
```

Under the local fork knowledge profile the report gains a per-scene
"fork fast paths" section (see below). For example, with
`cairo-fork-workers = 4` and `cairo-static-layers = true` in the profile:

```console
$ qual cost scene.py
...
  fork fast paths (profile production, knowledge local_0_20_1_4d25c031):
    fork-per-play (cairo_fork_workers 4):
      scene.py:9:9 play #1: no static blocker found (fork-eligible pending the runtime audit)
      scene.py:10:9 play #2: no static blocker found (fork-eligible pending the runtime audit)
    static layers (cairo_static_layers on):
      scene.py:9:9 play #1: no static blocker found
      scene.py:10:9 play #2: no static blocker found
    packed interpolation:
      scene.py:9:9 play #1: canonical per-member interpolation because the animation type FadeIn is outside the audited allowlist at scene.py:9:19 (blocker unsupported_animation_type); an updater-bearing mobject is in the scene family (updater registered here) at scene.py:8:9 (blocker updater_bearing_family)
      scene.py:10:9 play #2: canonical per-member interpolation because an updater-bearing mobject is in the scene family (updater registered here) at scene.py:8:9 (blocker updater_bearing_family)
      evidence: measured packed interpolation on the calibration machine, 300 members / 60 frames: 130.658 -> 33.004 ms/play, steady state 2.0761 -> 0.1890 ms/frame (docs/research/perf-evidence.md)
    note: the features named above can be correct expression; this section explains the render-path consequence and never advises removing them
```

## Analysis coverage

The analyzer's conservative silences are correct but invisible: a clean
run does not tell you whether there were no problems or whether half the
project could not be analyzed. `qual coverage` (and
`qual check --analysis-summary`, which prints the same report to
stderr without touching stdout or the exit code) surfaces everything the
analysis could **not** resolve:

For a file with a star import from an unresolvable module, a relative
import escaping the project tree, a `match` statement above
`target-python = "3.9"`, and a play wrapped in an unresolved helper call:

```console
$ qual coverage .
analysis coverage (knowledge profile upstream_0_20, target-python 3.9)

scene.py
  constructs above target-python (MLC000): 1
  star imports from unresolved modules: 1
  unresolved relative imports: 1
  calls with no resolved target: 1 of 6 (mystery x1)

scene scene.Demo (scene.py)
  plays with unknown duration: 1 of 2
  .animate builders with unknown target: 0 of 0

project
  files parsed: 1 of 1
  calls resolved: 5 of 6
  play durations known: 1 of 2
  scene constructors resolved: 1 of 1
  constructs above target-python (MLC000): 1
  unresolved imports: 2 (1 star, 1 relative)
  manim APIs not in the knowledge profile: 0
  helper calls summarized, not inlined: 0
  top unresolved calls: mystery x1

analysis confidence: 1/1 files parsed, 5/6 calls resolved, 1/2 play durations known, 1/1 scene constructors resolved (counts of analyzed facts, not estimates)
```

Every number is a count of computed facts; the only ratios are plain
`resolved / total` count pairs. `--format json` emits the same data as a
stable machine-readable document with top-level keys
`knowledge_profile`, `target_python`, `files[]` (`path`, `parsed`,
`gated_constructs`, `unresolved_star_imports`,
`unresolved_relative_imports`, `calls`, `unresolved_calls`,
`unresolved_call_names`, `apis_not_in_profile`), `scenes[]` (`name`,
`path`, `constructor_state_unknown`, `plays`,
`plays_with_unknown_duration`, `builders`,
`builders_with_unknown_target`), and `project` (the totals plus
`top_unresolved_call_names` and `helper_inline_fallbacks` — the helper
call sites where inlining fell back to an effect summary, recorded
project-wide and deduplicated across scenes sharing a helper chain, so
the count appears on `project` only). Output is deterministic and
byte-stable for identical inputs.

## CI integration

GitHub Actions annotations directly on the PR diff:

```yaml
name: qual
on: [push, pull_request]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo install --path . --locked
        working-directory: qual   # path to your qual checkout
      - run: qual check . --format github
```

Or upload SARIF so findings appear in the GitHub code-scanning UI:

```yaml
      - run: qual check . --format sarif > qual.sarif
        continue-on-error: true
      - uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: qual.sarif
```

## Pre-execution admission checks

Because `qual` never imports or executes the code it reads, a service
that renders user-supplied scenes can run it *before* spending sandbox,
CPU, and GPU time — rejecting scenes that provably fail at render, and
flagging ones whose cost model predicts a blow-up.

Untrusted input needs the whole contract, not just the rules:

- **Limits are enforced, not assumed.** Sources over 4 MiB, nesting deeper
  than 96, or prefix-operator runs longer than 64 are refused as `MLC000`
  before parsing, so hostile input cannot exhaust the stack (see
  [MLC000](docs/rules/MLC000.md)). Everything else remains the caller's
  budget: impose your own wall-clock and memory bounds on the process.
- **Start in observe mode.** Record findings alongside the real render
  outcome instead of blocking on them; promote a rule to blocking only once
  its prediction has been checked against what rendering actually did.
- **Block on `certain` only.** Correct source can carry error-severity
  findings on purpose — a test asserting `VGroup(3.0)` raises is the
  canonical case. Findings measured on a real corpus are in
  [docs/research/corpus-evidence.md](docs/research/corpus-evidence.md).

```bash
# Observe: record everything, never fail the request.
qual check "$SCENE_DIR" --format json --fail-level error > findings.json || true

# Block: refuse only what the analyzer is certain about.
qual check "$SCENE_DIR" --format json \
  --min-confidence certain --fail-level error
```

Exit code 1 means the threshold was met; exit code 2 is a usage or
configuration error and must never be treated as a rejected scene.

## Rule catalog

The catalog contains 92 rule IDs across four families; **all 92 are
implemented**:

| Family | Implemented | Reserved |
| --- | --- | --- |
| MLC lifecycle / correctness | 31 | 0 |
| MLR rendering | 27 | 0 |
| MLP performance | 27 | 0 |
| MLD determinism / portability | 7 | 0 |

One implemented rule is opt-in: `MLP225` has `default_enabled: false` and
never joins a normal `check` run; only an exact `--select MLP225` under the
local fork profile evaluates it.

The full index with per-rule status, severity, and confidence is in
[docs/rules/README.md](docs/rules/README.md); each implemented rule has a
documentation page there, also available via `qual explain <ID>`.

## Architecture

```text
Python sources
   |
SourceManager ............ encoding (PEP 263), newlines, Unicode columns
   |
knowledge profile ........ versioned Manim 0.20 semantics (no import, ever)
   |
frontend ................. imports/aliases, project index, qualified call facts
   |
semantic ................. lifecycle abstract interpreter -> LifecycleFacts
   |
cost ..................... hot contexts, frame intervals -> CostFacts
   |
rules .................... MLC / MLR / MLP / MLD over the fact layers
   |
suppressions, supersedes, baseline
   |
output ................... concise | full | json | sarif | github, fixes, cost report
```

[docs/architecture.md](docs/architecture.md) walks a new contributor
through this pipeline: what each fact layer provides, where it lives, the
knowledge-profile system, and how one diagnostic flows end to end.
[`DESIGN.md`](DESIGN.md) is the authoritative specification for the semantic
model, the rule catalog, and every public contract. JSON output follows
[`schemas/diagnostics-v1.json`](schemas/diagnostics-v1.json); baselines
follow [`schemas/baseline-v1.json`](schemas/baseline-v1.json). The
Poietra/fast-manim semantic bridge emitted by `qual static-facts` is
specified by
[`StaticFacts v0`](docs/rfcs/0001-static-facts-v0.md) and its
[`JSON Schema`](schemas/static-facts-v0.json). It publishes snapshot-scoped
Scene/object/play/animation/updater IDs, encoding-aware source anchors,
reason-carrying unknowns, renderer risks, and coverage frontiers without
exposing analyzer handles. It reports blockers but never grants permission to
skip or fork rendering. Output is deterministic and byte-stable for the same
input.

The cache-independent
[`SemanticDependencyGraph v0`](docs/rfcs/0002-semantic-dependency-graph-v0.md)
is the shared fact layer for cache component partitioning and conservative
source-change impact. It retains anchored Unknown frontiers instead of
guessing dynamic dependency edges.
[`ChangeImpact v0`](docs/rfcs/0003-change-impact-v0.md) compares two source
snapshots and emits schema-validated, reason-carrying Scene/play/object impact
candidates, including relations deleted from the target tree.
[`SourceBridge v0`](docs/rfcs/0004-source-bridge-v0.md) generates hash-guarded
local patch candidates, validates them against an in-memory reanalysis, and
reports `match | ambiguous | missing` without writing project files.

## Known limitations

- **Target version.** The shipped knowledge profile covers Manim Community
  **0.20 only**. Other versions have no profile yet.
- **Asset checks probe the linting machine.** `MLR104` resolves literal
  asset paths with Manim's own runtime search, on the machine running the
  lint. For absolute paths outside the project tree that is evidence about
  the lint host, not necessarily the render host (e.g. CI linting a repo
  rendered elsewhere); those diagnostics carry `environment_dependent: true`
  as evidence. Case-only mismatches are reported only against case-sensitive
  target platforms (`linux`); when every affected profile targets
  windows/macos, the declared renders resolve the file as written and the
  linter stays silent.
- **Source encodings.** PEP 263 declarations resolve through WHATWG labels
  plus a CPython codec-alias table (`latin-1`, `cp932`, `koi8_r`, ...). A
  rare Python codec the linter cannot represent is skipped with an explicit
  `MLC000` "not supported by qual" notice — never a claim that the
  target Python could not decode the file.
- **Durations come from literals only.** A play whose duration rests on
  Manim's *defaults* (`self.play(m.animate.shift(RIGHT))` with no
  `run_time` anywhere, `self.wait()`) is reported as unknown — frame counts
  use per-frame wording instead of numbers (conservative: missing, never
  fabricated). A literal play-level `run_time` decides the whole-play
  duration exactly — including plays inside Scene helpers, per call site,
  even when the call sites pass different (or untracked) mobjects to a
  `.animate` builder on the parameter — and a *non-literal* `run_time`
  (or a `**kwargs` splat) honestly widens constructor literals it
  overrides.
- **Summary-derived plays are conservative.** When helper inlining falls
  back to an effect summary (recursion, an unresolvable call — counted as
  `helper calls summarized, not inlined` in the coverage report), the
  helper's plays still materialize, but as `Maybe`-certainty records with
  open repetitions: literal-duration checks such as `MLC104` still fire
  there, while every caller-state-dependent judgment stays degraded.
- **`TracedPath`'s constructor-registered updater is cost-only.** The
  updater `TracedPath` registers on itself at construction is modeled for
  cost purposes (`MLP220` spans, hot-context entry for a traced lambda),
  but it is not a lifecycle updater registration: a `TracedPath` alone
  does not make a default `wait()` dynamic in the lifecycle model, and a
  bound-method `traced_point_func` body is not analyzed as a hot context.
- **Deliberately conservative silences.** Some detections are narrower than
  their catalog prose and stay silent rather than guess: `MLR106` sees
  NaN/inf only in literal form, not through `float("nan")` calls; `MLD301`
  proves FPS dependence only for updaters that lack a `dt` parameter (a
  declared-but-unused `dt` is not flagged); `MLC113`/`MLC124` recognize
  their documented call shapes only; `MLR102` needs the interpreter to prove
  the played bare builder's target unchanged; `MLR105` validates a verified
  Pango subset (a bare `&` is allowed); `MLD304` implements only the
  ThreeDScene fixed-object cleanup divergence. `qual explain <RULE>`
  states each rule's exact scope.
- **Not yet implemented.** Threshold calibration against rendered baselines;
  a nightly render-comparison CI.

## Development

```bash
cargo fmt --check
cargo build
cargo test --all-features
cargo clippy --all-targets --all-features -- -D warnings
```

All four gates must pass.

Knowledge-profile maintenance: the `sync_manim_knowledge` binary statically
reads a Manim checkout, generates reviewable profile candidates, and checks
the shipped profiles for drift (exit 1 on contradictions) — see
[src/knowledge/profiles/README.md](src/knowledge/profiles/README.md).
Provenance is split: `upstream_0_20` describes the **clean** upstream base
commit `4d25c031` (read via `git archive`, never the working tree), and the
`local_0_20_1_4d25c031` overlay carries what the sibling fork's working
tree adds on top:

```bash
# working tree (fork) — informational against upstream
cargo run --features dev-tools --bin sync_manim_knowledge -- --manim-root ../manim --diff
# clean upstream base — must be contradiction-free
cargo run --features dev-tools --bin sync_manim_knowledge -- --manim-root ../manim --manim-ref 4d25c031 --diff
cargo test --test knowledge_drift -- --ignored   # layer-9 drift gate (both)
```

### Release quality gates (DESIGN §11.4)

Three additional gates guard releases:

```bash
# Labeled corpus gate — runs automatically inside `cargo test`.
# tests/corpus/manifest-v1.json pins sha256 + exact expected diagnostics
# (true positives and false-positive guards) for every corpus case,
# including pinned real-Manim example_scenes snapshots and the
# adversarial review probes.
cargo test --test corpus_gate

# Benchmark gate — explicit, release build, quiet machine.
# Cold ≤ 2 s / warm hit ≤ 0.5 s / one-of-20 incremental ≤ 0.5 s /
# peak RSS < 300 MiB over the pinned 10k-LOC fixture
# (tests/corpus/benchmark_10kloc); thresholds assert only on the machine
# matching benchmarks/reference-machine.json, informational elsewhere.
# The gate proves cold is a miss, warm a validated hit, and incremental a partial hit.
# Three-run median on the reference machine (2026-07-20): cold 0.422 s,
# warm 0.006 s, incremental 0.171 s, peak RSS 246.5 MiB — all within budget.
cargo test --release --test benchmark_gate -- --ignored benchmark

# Knowledge drift gate — needs the sibling Manim checkout; in CI it runs
# on schedule/dispatch against a shallow clone of the pinned base commit.
cargo test --test knowledge_drift -- --ignored
```

Corpus cases are never re-recorded mechanically: a mismatch means
re-adjudication under the labeling protocol in
[CONTRIBUTING.md](CONTRIBUTING.md#corpus-labeling).

See [CONTRIBUTING.md](CONTRIBUTING.md) for the
repository layout, the step-by-step guide to adding a rule, and the
invariants every change must keep, and
[docs/architecture.md](docs/architecture.md) for the pipeline and
fact-layer overview. `DESIGN.md` is authoritative; changes to
public contracts must update it, its schema tests, and the rule docs
together.

## License

[MIT](LICENSE).

Dependency licenses, and one consequence worth knowing before you ship a
prebuilt binary — the Python parser pulls in an LGPL-3.0-only big-integer
crate, which Rust links statically — are documented in
[THIRD-PARTY-LICENSES.md](THIRD-PARTY-LICENSES.md). Installing from source
(`cargo install`) is unaffected.

Prebuilt releases include the LGPL/GPL texts, exact locked source, and the
[relinking instructions](RELINKING.md) in every distribution format. The
release gate refuses to publish when that material is missing.

`qual` is an independent project. Manim Community is not affiliated
with it and does not endorse it.

