diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..f289c09 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,57 @@ +name: Docs + +on: + push: + branches: [main] + paths: + - ".github/workflows/docs.yml" + - "docs/**" + - "mkdocs.yml" + pull_request: + paths: + - ".github/workflows/docs.yml" + - "docs/**" + - "mkdocs.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: docs/requirements.txt + - name: Install documentation dependencies + run: python -m pip install --requirement docs/requirements.txt + - name: Build documentation + run: mkdocs build --strict + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: site + + deploy: + if: github.event_name != 'pull_request' + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.gitignore b/.gitignore index 80fab9a..5d54904 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target .qual-cache/ .claude/ +/site/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4647e2a..cf74505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- A searchable Material for MkDocs site that brings installation, configuration, + adoption, cost/coverage, all 92 rule pages, CLI reference, JSON contracts, + RFCs, architecture, and research evidence into one GitHub Pages deployment. + +### Changed + +- README, CLI, GitHub, crates.io, and PyPI-facing copy now lead with Qual as + the Manim-aware linter: render-time errors, visual bugs, and per-frame + performance traps before rendering. The README is a concise product entry + point, while detailed operation and integration material lives in the docs. + ## [0.3.0] - 2026-07-30 ### Added diff --git a/Cargo.toml b/Cargo.toml index 68bc488..0631ae3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,12 +3,12 @@ name = "qual" version = "0.3.0" edition = "2024" rust-version = "1.85" -description = "Static lifecycle and performance analysis for Manim scenes" +description = "A Manim-aware linter for render-time errors, visual bugs, and per-frame performance traps" license = "MIT" publish = ["crates-io"] repository = "https://github.com/Poietra/qual" -homepage = "https://github.com/Poietra/qual" -documentation = "https://github.com/Poietra/qual#readme" +homepage = "https://poietra.github.io/qual/" +documentation = "https://poietra.github.io/qual/" readme = "README.md" keywords = ["manim", "linter", "static-analysis", "animation", "python"] categories = ["development-tools", "command-line-utilities"] diff --git a/README.ja.md b/README.ja.md index e6a8c7f..71fe99e 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,79 +1,93 @@ # Qual -**[Manim Community](https://www.manim.community/) のシーンを静的解析 — レンダリング前に、確実な実行時エラー・意図と異なる描画・性能の乗数・非決定性を検出する。** +**Manimを理解するlinter。壊れたレンダリングを、レンダリング前に。** -[English](README.md) | 日本語 +[![Release](https://img.shields.io/github/v/release/Poietra/qual)](https://github.com/Poietra/qual/releases/latest) +[![PyPI](https://img.shields.io/pypi/v/qual-manim)](https://pypi.org/project/qual-manim/) +[![crates.io](https://img.shields.io/crates/v/qual)](https://crates.io/crates/qual) +[![CI](https://github.com/Poietra/qual/actions/workflows/ci.yml/badge.svg)](https://github.com/Poietra/qual/actions/workflows/ci.yml) +[![License](https://img.shields.io/github/license/Poietra/qual)](LICENSE) -`qual` は Manim Community **0.20** 系プロジェクト向けの独立した静的解析器で、Rust で実装されています。Python ソースをパースし、検証済み・バージョン管理された Manim 意味モデルと照合します。Manim も解析対象コードも **決して import・実行しません**。API 名のパターンマッチではなく、`Scene.play` の実際の挙動(引数のコンパイル、auto-add、introducer / remover、updater)を再現するライフサイクル抽象解釈器と、「どのコードが 1 回だけ実行され、どのコードが毎フレーム実行されるか」を把握する記号的コストモデルの上で診断します。 +[ドキュメント](https://poietra.github.io/qual/) · +[全ルール](https://poietra.github.io/qual/rules/) · +[English](README.md) -## 例 +Manimコードに対するRuffのようなフィードバックに、`Scene.play`、Mobjectの +ライフサイクル、updater、Cairo/OpenGL、毎フレームの描画コストへの理解を +加えた静的解析ツールです。ManimもSceneも実行せず、実行時エラー・気づきにくい +誤描画・性能問題を検出します。 -`scenes/demo.py`: +```bash +uv tool install qual-manim +qual check . +``` + +> RuffやPyrightはPythonを検査し、QualはManimがそのコードをどう扱うかを検査します。 + +## 一般的なPython linterには分からない問題 ```python from manim import * -class TrackerDemo(Scene): +class Demo(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) + label = always_redraw( + lambda: MathTex(f"x={tracker.get_value():.2f}") + ) + self.add(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. +scene.py:9:21: 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. +scene.py:12: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()`. ``` -このうち 2 件はレンダリングをクラッシュさせ(`MLC102`、`MLC104`)、1 件はタイトルを不可視のまま描画し(`MLR115`)、1 件はフレームレートによって動きの速さが変わり(`MLD301`)、1 件は動くはずの wait を凍結し(`MLC112`)、1 件は毎フレーム新しいキャッシュキーで外部 TeX コンパイラを起動します(`MLP226`)— しかもそのコストは定量化されます: 60 FPS で `run_time=8` なら少なくとも約 480 個の相異なるキーであることが証明できます。 - -## 検出できるもの +`MLC102`はレンダリングを中断させます。`MLP226`は毎フレーム異なるTeXアセットを +作る可能性があります。QualはManimのライフサイクルを追い、「一度だけ」と +「毎フレーム」を区別するため、両方を検出できます。フレーム数などの数値は、 +ソースと選択したレンダープロファイルから証明できる場合だけ表示します。 -ルールは 4 つのファミリーに分かれます。 +## 検出するもの -- **MLC — ライフサイクル / 正しさ。** 確実な実行時エラーと、誤った絵を描くライフサイクル上の誤り。`Scene.play` への非 Animation 引数(`MLC102`)、全パスで `generate_target()` を欠く `MoveToTarget`(`MLC107`)、`save_state()` なしの `Restore`(`MLC120`)、一つの play 内で同じ mobject の同じチャンネルへ書き込む 2 つのアニメーション(`MLC108`)、生き残った親の再追加で無効化される `Scene.remove(child)`(`MLC115`)。 -- **MLR — レンダリング。** 描画はされるが意図した絵にならないコード。非 raw の `MathTex` リテラル内で TeX コマンドを壊す Python エスケープ(`MLR103`)、Manim の実際のランタイム探索で解決できないアセットパス(`MLR104`)、プレーンな `Text` に渡された Pango マークアップ(`MLR124`)、`Transform(mob, mob)`(`MLR113`)。 -- **MLP — パフォーマンス。** 機械可読な根拠付きのコスト乗数。updater や `always_redraw` 内での `Text`/`MathTex`/`SVGMobject` 構築(`MLP201`)、毎フレーム 1 つのディスクアセットを生む frame 依存 TeX キャッシュキー(`MLP226`)、毎フレーム成長するシーングラフ(`MLP204`)、`dissipating_time` なしの `TracedPath`(`MLP220`)。 -- **MLD — 決定性 / 可搬性。** マシン・フレームレート・レンダラーによって結果が変わるコード。`dt` でスケールされない固定の毎フレームステップ(`MLD301`)、フレームコールバック内の未シード global random(`MLD302`)、大文字小文字を区別するプラットフォームでの case-only アセットパス不一致(`MLD305`)。 +Qual 0.3は、4ファミリーに分かれた**92個の実装済みManim固有ルール**を +提供します。 -### 名前照合ではなく意味の深さ +| ファミリー | 検出対象 | +| --- | --- | +| **MLC — ライフサイクルと正しさ** | 不正なAnimation、target/state不足、updater、同時書き込み、Scene所属の誤り | +| **MLR — レンダリング** | TeX、asset、geometry、描画順、camera、Cairo/OpenGL互換性による誤描画 | +| **MLP — パフォーマンス** | 毎フレームの構築、成長するScene graph、重いcallback、rasterやresource keyの乗数 | +| **MLD — 決定性と可搬性** | FPS依存の動き、未seed乱数、platform path、font、frame callback内の外部状態 | -解析器の中心原則(DESIGN §1)は「API 名だけで警告しない」ことです。未追加の mobject への `FadeOut(mob)` は正しいコードです — play の準備段階で自動追加され、remover として終了後に削除されます。そのためパイプラインはまず実際の事実を構築します。 +各診断は**severity**(`error` / `warning` / `info`)と**confidence** +(`certain` / `high` / `medium` / `low`)を分けて持ちます。静的に解決できない +挙動は`Unknown`にし、不確実性から高確度の警告を作りません。 -- **ライフサイクル抽象解釈器**: 関数内 CFG、手続き間ヘルパー要約、`super()` ディスパッチを含む Scene ごとの MRO 合成、割り当てサイト同一性、Scene 所属 / 順序 / updater の追跡、play グループの意味論。 -- **記号的コストモデル**: hot context の伝播(updater、`always_redraw`、stop condition、interpolate オーバーライド)と、リテラルな duration からのみ導出されるフレーム数区間。上のコストレポートが `duration 8 s -> frames ~480` と言えるのは 60 FPS で `run_time=8` が証明できるからで、証明できない値は `unknown` と表示し、数値を捏造しません。 - -すべての診断は **severity**(`error`/`warning`/`info`)と **confidence**(`certain`/`high`/`medium`/`low`)を分離して持ち、状態依存ルールは全パスで確定した根拠でのみ発火します。静的に解決できない値は `Unknown` に落とし、**推測するより沈黙する** — この設計姿勢を全ルールで貫いています。 +[全ルールと正確な検出範囲を見る →](https://poietra.github.io/qual/rules/) ## インストール -公開済みリリースは、次のどの入口からでもインストールできます。PyPI -パッケージが入れるものは Rust ネイティブ実行ファイルで、実行時に Manim を -import せず、Python ランタイムも必要としません。 +PyPIパッケージはRust製ネイティブ実行ファイルをインストールします。利用時に +Manim、LaTeX、Pythonランタイムは必要ありません。 ```bash -# Python ツールとして +# Pythonツールとして uv tool install qual-manim -# または: pipx install qual-manim +# または +pipx install qual-manim -# Rust ツールとして(ソースからビルド、Rust 1.85+) +# Rustツールとして(Rust 1.85以上) cargo install qual --locked ``` -Linux・macOS・Windows 向けの standalone installer とチェックサム付き -archive は各 GitHub Release に添付されます。 +Linux・macOS・Windows向けのstandalone installerとchecksum付きarchiveは、 +各[GitHub Release](https://github.com/Poietra/qual/releases/latest)にあります。 ```bash # macOS / Linux @@ -81,439 +95,95 @@ curl --proto '=https' --tlsv1.2 -LsSf \ https://github.com/Poietra/qual/releases/latest/download/qual-installer.sh | sh ``` -最初の registry release より前、または現在の checkout を使う場合はソースから -インストールしてください。 - -```bash -git clone https://github.com/Poietra/qual.git -cd qual -cargo install --path . -``` - -## クイックスタート +## ローカルとCIで使う ```bash -qual check . # 端末なら rich、パイプなら concise -qual check . --format rich # ソースフレームと色を強制 -qual check . --format concise # 1 診断 1 行 -qual check scenes --format full # 説明と根拠つき -qual check . --format json # schemas/diagnostics-v1.json 準拠 -qual check . --format sarif # SARIF 2.1.0 -qual check . --format github # GitHub Actions アノテーション -qual explain MLC102 # ルールの完全なドキュメント -qual rules # 全ルール ID・フェーズ・実装状態 -qual config # 解決済みの有効な設定 -qual cost scenes/demo.py # シーンごとのコスト内訳 -qual coverage . # 解析が解決できなかったものの一覧 -qual static-facts . > facts.json # StaticFacts v0の意味projection -``` - -終了コード: `0` — `fail-level` に達する報告済み診断なし。`1` — 1 件以上あり。`2` — コマンドライン / 設定 / 内部エラー。 - -### 出力フォーマット - -`--format` を指定しない場合、`check` は出力先を見て形式を決めます。端末に接続していれば `rich` — 診断ごとのバナー、該当行のソースと下線、説明、そして集計 — を表示します。 - -```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 -``` - -ファイルやパイプへリダイレクトした場合は `concise` — 1 診断 1 行、エスケープシーケンスなし — になるため、既存のスクリプトや CI が解析している形式はそのまま保たれます。`--format` を渡せばどちらの方向にも上書きできます。 - -色は `--color auto|always|never` に従います。`auto` は端末のときだけ着色し、`NO_COLOR`(値は何でも可)が設定されていれば着色しません。`--color always` はリダイレクト時も着色します。着色されるのは `rich` だけです。`COLUMNS` はバナー幅と折り返し幅に使われます。 - -主な `check` オプション: `--select` / `--ignore`、`--min-confidence`、`--fail-level`、`--profile`、`--renderer`、`--fps`、`--resolution WIDTHxHEIGHT`、`--color`、`--statistics`、`--analysis-summary`(後述の解析カバレッジレポートを診断の後に stderr へ出力。stdout と終了コードは変化しない)、および後述の baseline / fix オプション。`--no-cache` はキャッシュを読み書きせず、cache directory も作らずに完全解析を強制します。`--select` は解析そのものも絞り込みます: 選択したルールが必要としない事実レイヤー(ライフサイクル解釈器、記号的コストモデル)の計算をスキップするため、狭い select はフル実行より高速です。報告される診断はどちらでも同一です — 選択したルールを supersede するルールは常に実行されるので、狭い select が supersede 済みの診断を復活させることはありません。 - -`--format full` は各診断の下に説明と機械可読な根拠を表示します。 - -```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 -``` - -## 解析キャッシュ - -通常の `check` は破棄可能な SQLite cache を -`.qual-cache/cache-v2.sqlite3` に保持します。同一入力の二回目はfilesystem -dependencyを検証し、frontendを起動せずwhole-project diagnostics JSONを再利用 -します。source変更後は全projectをparse/indexしてから、解決済みimport、call、 -base class、module名collisionを弱連結componentへまとめます。変更のないcomponent -はmethod summaryとfilter済みdiagnosticsのJSONを再利用し、変更componentだけ -summary、Scene lifecycle、cost解析をやり直します。ASTや解析対象codeは保存も -実行もしません。 - -keyにはanalyzer build、semantic config、Manim knowledge profile、全source layout、 -該当componentのsource bytesが含まれます。literal asset候補とcase-sensitiveな -directory walkもentryごとにstampするため、asset変更は該当componentを無効化 -します。SQLite WALは並行cold writerを許容し、直近16個のproject snapshotと -256個のcomponent snapshotを保持します。cacheは正しさに必要な状態ではなく、 -破損時はwarning付きで再構築し、その他のfailureでも解析を継続します。 -`--no-cache`はcache filesystem accessをすべて無効にします。`--fix`、baseline、 -`--analysis-summary`はlive source/index stateが必要なため完全解析します。 -projectのignore fileには`.qual-cache/`を追加してください。旧 -`cache-v1.sqlite3`は使われないため削除できます。 - -cold runは依存しないsummary component、Scene lifecycle、ruleをbounded worker -poolで並列化します。再帰summaryのfixpointとfrontend/project indexは順序を -保った逐次処理です。結果は安定sortされ、worker 1件と4件でJSONがbyte単位に -同一であることをtestしています。 - -## 設定 - -設定は `pyproject.toml` の `[tool.qual]` から読み込みます(検査対象パスから上方向へ探索)。レンダープロファイルは `[[tool.qual.profile]]` エントリです。 - -```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"] -``` - -優先順位(高い順): - -```text -CLI > selected profile > pyproject base > manim.cfg > builtin defaults -``` - -`respect-manim-cfg` が有効(既定)なら、`manim.cfg` が解像度 / fps / レンダラーの既定値を pyproject 設定の下位として補います。未知のキー、未知のルールセレクター、重複するプロファイル名、未定義プロファイルへの参照は設定エラー(exit 2)です。`--profile all` は定義済みの全プロファイルを解析し、同じ根拠の診断を 1 件へ統合して、影響するプロファイルを診断ごとに列挙します。 - -設定は正直に検証されます(違反は exit 2): - -- 宣言した `manim-version` は、設定した knowledge profile が対応する Manim 範囲内でなければなりません(例: `upstream_0_20` は `>=0.20,<0.21` に対応)。未宣言なら検証しません。 -- `target-python` は `MAJOR.MINOR` 形式で 3.6〜3.12 の範囲に収まる必要があります。上限は同梱パーサー(rustpython-parser 0.4)が実装する Python 文法、下限は構文ゲートの完全性を保証できるフロアです(それより古い target は黙って放置されず exit 2 で拒否されます)。文法は固定で(`feature_version` の指定はなし)パース結果は変わりませんが、パース後のゲートが AST・トークン列・f-string テキストを走査し、target より新しい構文をすべて `MLC000` として報告します(`async def` 外の `async`/`await` 構文 3.7、`:=`・位置専用引数 `/`・f-string の自己文書化 `=` 3.8、拡張デコレーターと `as` 付き括弧付きコンテキストマネージャー 3.9、`match` 3.10、`except*` と PEP 646 の添字 `*` アンパック 3.11、`type` エイリアス・PEP 695 型パラメーター・PEP 701 f-string 式 3.12)。ゲートを無警告で通過したファイルは target 自身のパーサーで必ずパース可能です。ゲートされたファイルも解析は継続し、対象構文を持ち込む `--fix` はロールバックされます。カバレッジ表は `qual explain MLC000` を参照してください。 -- ゼロ・負・非有限のフレームレートと、寸法が 0 の解像度は、どの経路(`--fps` / `--resolution`、プロファイル、`manim.cfg`)から来ても拒否されます。 -- `stub-paths` は未実装です。空でないリストは黙って無視されず、設定エラーになります。 - -`qual config` は解決済み設定に加えて、どの設定が強制され、どれが情報提供のみかを示す `enforcement` セクションを出力します。 - -## 最適化フォークプロファイルの利用 - -ローカルにパッチを当てた Manim フォーク(プロファイル `local_0_20_1_4d25c031`)でレンダリングするプロジェクトは、それを qual に伝えることでフォーク固有の解析レイヤーを有効化できます: - -```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 -``` - -upstream プロファイルが提供するすべてに加えて、次が有効になります: - -- **`qual cost` の「fork fast paths」セクション**: play ごとに、fork-per-play Cairo パイプライン(`cairo-fork-workers`)、静的レイヤー保持パス(`cairo-static-layers`)、packed interpolation が適用されるかどうかを表示します。適用されない場合は正確なブロッカーとそのソース位置(例: Scene updater)を示し、最初の serial play 以降のレンダラー全体に及ぶ単調な無効化の因果連鎖も説明します。このセクションは機能の削除を助言することは決してなく、レンダーパス上の帰結を説明するだけです。 -- **`MLP214`**: シーン最初の play より前に 4 個以上の相異なる TeX コンパイルキーが直列に構築される箇所を指摘し、フォークの事前コンパイル API(`MathTex.precompile`、`tex_to_svg_file_async`)を提示します。 -- **`MLP217`**: hot なコールバック内でフレームごとに変わる `use_svg_cache=True` キーが、フォークが宣言するプロセスグローバル SVG キャッシュを毎フレーム成長させる箇所を指摘します。 -- **`MLP225`**(`--select MLP225` によるオプトイン): cost レポートの fast-path ブロッカー説明を play ごとの診断として出力します。 - -`upstream_0_20` の下では上記はすべて不活性です: cost レポートに fork セクションは現れず、3 つのルールは選択されても決して発火しません。 - -## 抑制(suppression) - -```python -self.play(square.shift(RIGHT)) # qual: ignore[MLC102] # 同じ文 - -# qual: ignore[MLP201] # 次の文 -label = always_redraw(...) - -# qual: file-ignore[MLP] # ファイル全体。ファイルヘッダー領域に置く +qual check . # richな端末表示 +qual check . --format concise # 1診断1行 +qual check . --format github # GitHub Actions annotation +qual check . --format sarif # SARIF 2.1.0 +qual check . --fix # safe fixのみ +qual cost scenes/demo.py # Sceneごとの記号的コスト +qual coverage . # 解決できなかった解析範囲 +qual explain MLC102 # 1ルールの完全な説明 ``` -抑制の対象は行単位ではなく**文単位**です。行末コメント(または直上の独立コメント)は、複数行にまたがる呼び出しの継続行も含めた文全体をカバーし、その文の内部のどこに位置する診断でも抑制されます。複合文(`def`、`for`、`if`、`with` など)ではヘッダー(コロンまで)だけをカバーし、1 つのコメントがスイート全体を沈黙させることはありません。 - -インライン抑制内の未知のルール ID は何も抑制せず、専用の警告として報告されます。 - -```text -scene.py:8:41: MLC001 warning unknown rule ID in suppression: MLC999 -``` - -ディレクトリ単位には `pyproject.toml` の `per-file-ignores` を使ってください(上の例を参照)。 - -## 段階導入: baseline - -既存プロジェクトに、全部を直す前から導入できます。 +終了コードは、`0`が失敗閾値未満、`1`が閾値に達する診断あり、`2`が入力・設定・ +内部エラーです。既存プロジェクトではbaselineを使って新規診断だけを検出できます。 ```bash -qual check . --write-baseline .qual-baseline.json # 今日の検出結果を記録 -qual check . --baseline .qual-baseline.json # 新規の検出だけを報告 +qual check . --write-baseline .qual-baseline.json +qual check . --baseline .qual-baseline.json ``` -baseline の指紋(`schemas/baseline-v1.json`)は **行番号を含みません** — ルール ID、相対パス、修飾された Scene 名、周辺トークンのハッシュから作られるため、ファイル内の無関係な行の追加でエントリが失効しません。`scene` フィールドには修飾された囲み Scene クラス名が記録され(Scene の外では空)、別々の Scene にある同一の検出は異なる指紋になります。書き出されるファイルには来歴マーカー `scene_attribution: "attributed"` が付き、そのファイルでは空の `scene` は文字どおり「どの Scene の外」を意味して厳密に一致します。Scene 帰属導入前に書かれた baseline(マーカーなし)も引き続き読み込め、その場合に限り空の `scene` がワイルドカードとしてマッチします。破損した、あるいはスキーマの合わない baseline ファイルは明確なメッセージとともに exit 2 になります。 +設定は`pyproject.toml`の`[tool.qual]`に置きます。profileにrenderer、platform、 +解像度、FPSを定義できます。詳細は +[設定ガイド](https://poietra.github.io/qual/guides/configuration/)を参照してください。 -## 自動修正(autofix) +## Ruff・Pyrightとの役割分担 ```bash -qual check . --fix # SAFE な修正だけを適用 -qual check . --fix --unsafe-fixes # UNSAFE な修正も適用 -``` - -safe と unsafe は厳密に分離されています。`--fix` 単体では挙動を変えない編集だけを適用します(例: `MLC127` は 1 回の `add()`/`VGroup()` 呼び出しから重複した子を除去、`MLR104` は case-only のアセットパスを修正)。unsafe な修正は実行時の意味を変え得るため(例: `MLC102` の `play(mob.shift(...))` → `play(mob.animate.shift(...))` への書き換え)、明示的な追加フラグが必要です。修正されたファイルはすべて再パースで検証され、検証に失敗したファイルはロールバックされます。 - -```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 コマンド - -`qual cost` はシーンごとの記号的コスト内訳を表示します — フレーム数区間つきの play リスト、由来とコールバックの実行が証明された play つきの hot context、毎フレーム構築、リソースキーの成長。未知の duration は unknown と表示し、数値を捏造しません。 - -```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) -``` - -ローカルフォークの knowledge profile の下では、レポートにシーンごとの「fork fast paths」セクションが加わります(上の「最適化フォークプロファイルの利用」を参照)。例えばプロファイルに `cairo-fork-workers = 4` と `cairo-static-layers = true` を設定した場合: - -```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 +ruff check . +pyright +qual check . ``` -## 解析カバレッジ - -保守的な沈黙は正しくても見えません。クリーンな実行が「問題なし」なのか「半分しか解析できなかった」のか — その沈黙は安全なのか、それとも盲目なのか — を区別できるように、`qual coverage`(および同じレポートを stderr に出す `qual check --analysis-summary`)は解析が解決**できなかった**ものを列挙します: 未解決の import(不明モジュールからの star import、プロジェクト木を出る相対 import)、候補が空の呼び出し、duration 不明の play、対象不明の `.animate` ビルダー、`target-python` を超える構文(MLC000)、knowledge profile に無い manim API、コンストラクタ状態が不明なシーン、インライン化されずサマリーへフォールバックしたヘルパー呼び出し(再帰・解決不能。プロジェクト全体で重複排除して集計)。 +- Ruff: style、import、Python一般のlint +- Pyright: Pythonの型 +- Qual: Manimのライフサイクル、描画意味論、描画コスト -解決できないモジュールからの star import、プロジェクト木を出る相対 import、`target-python = "3.9"` を超える `match` 文、未解決ヘルパー呼び出しに包まれた play を含むファイルの例: +QualはRuff pluginではありません。Manim固有の問題は、複数文、helper、Scene所属、 +Animationのsetup/cleanup、render profileをまたいで解析する必要があるため、独立した +解析器として動作します。 -```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) -``` +## ドキュメントと機械向けAPI -すべての数値は計算済みファクトの個数であり、比率は「解決済み / 総数」の単純なカウント対のみです。`--format json` は安定したトップレベルキー `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`)、`project`(総計に加えて `top_unresolved_call_names` と `helper_inline_fallbacks` — サマリーへフォールバックしたヘルパー呼び出しサイト数。ヘルパー連鎖を共有するシーン間で重複排除されるため `project` にのみ現れます)を持つ機械可読ドキュメントを出力します。出力は決定的で、同一入力に対してバイト単位で安定です。 +[ドキュメントサイト](https://poietra.github.io/qual/)では次を横断検索できます。 -## CI 連携 +- インストール、設定、CI、baseline、suppression、fix +- 92ルールすべての根拠とnear-miss +- `qual cost`と`qual coverage` +- architecture、実測根拠、contributor向け資料 +- versioned JSON contractとschema -GitHub Actions アノテーションを PR の差分に直接表示する場合: +公開される連携面はCLIとversioned JSONです。ネットワークAPIや公開Rust libraryでは +ありません。 -```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 # qual checkout へのパス - - run: qual check . --format github -``` - -SARIF をアップロードして 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 +```bash +qual check . --format json +qual static-facts . +qual change-impact --before old-tree --after new-tree +qual source-bridge . --request request.json ``` -## ルールカタログ - -4 ファミリーに 92 個のルール ID があり、**92 個すべて実装済み**です。 - -| ファミリー | 実装済み | reserved | -| --- | --- | --- | -| MLC ライフサイクル / 正しさ | 31 | 0 | -| MLR レンダリング | 27 | 0 | -| MLP パフォーマンス | 27 | 0 | -| MLD 決定性 / 可搬性 | 7 | 0 | - -実装済みルールのうち 1 つはオプトインです: `MLP225` は `default_enabled: false` で、通常の `check` 実行には決して参加しません。ローカルフォークプロファイルの下で正確な `--select MLP225` を指定したときだけ評価されます。 - -ルールごとの状態・severity・confidence を含む完全な索引は [docs/rules/README.md](docs/rules/README.md) にあります。実装済みルールにはそれぞれドキュメントページがあり、`qual explain ` でも読めます。 - -## アーキテクチャ - -```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 -``` +[機械向けAPI概要](https://poietra.github.io/qual/reference/machine-api/)から各RFCと +JSON Schemaへ移動できます。 -[docs/architecture.md](docs/architecture.md)(英語)は新しいコントリビューター向けにこのパイプラインを解説します: 各事実レイヤーが何を提供しどこにあるか、knowledge profile システム、そして 1 つの診断がエンドツーエンドでどう流れるか。意味モデル・ルールカタログ・公開契約の正典仕様は [`DESIGN.md`](DESIGN.md) です。JSON 出力は [`schemas/diagnostics-v1.json`](schemas/diagnostics-v1.json)、baseline は [`schemas/baseline-v1.json`](schemas/baseline-v1.json) に従います。`qual static-facts`が出力するPoietra / fast-manim向け意味bridgeは[`StaticFacts v0 RFC`](docs/rfcs/0001-static-facts-v0.md)とその[`JSON Schema`](schemas/static-facts-v0.json)に従います。snapshot内のScene/object/play/animation/updater ID、encoding-awareなsource anchor、理由付きUnknown、renderer risk、coverage frontierを公開しますが、描画省略やforkの許可は出しません。出力は決定的で、同じ入力に対して byte 単位で安定です。 +## 安全性と現在の範囲 -## 既知の制限 +- Manim、plugin、解析対象コードをimport・実行しません。 +- 現在のknowledge profileは**Manim Community 0.20**を対象にします。 +- asset検査はManimの探索順をモデル化し、lint実行環境のfilesystemを参照します。 +- 動的Pythonは保守的に扱い、`qual coverage`で未解決範囲を確認できます。 +- Python一般のstyleや型エラーはRuff・Pyrightに任せます。 -- **対象バージョン。** 同梱の knowledge profile は Manim Community **0.20 のみ** を対象とします。他のバージョンのプロファイルはまだありません。 -- **アセット検査は lint 実行マシンを調べます。** `MLR104` はリテラルなアセットパスを、lint を実行しているマシン上で Manim 自身のランタイム探索により解決します。プロジェクトツリー外の絶対パスについては、それは lint ホストに関する証拠であり、必ずしもレンダーホストのものではありません(例: CI で lint し、別マシンでレンダーするリポジトリ)。そのような診断は根拠として `environment_dependent: true` を持ちます。case-only の不一致は大文字小文字を区別する対象プラットフォーム(`linux`)に対してのみ報告されます。影響するプロファイルがすべて windows / macos を対象とする場合、宣言されたレンダーは書かれたとおりにファイルを解決できるため、linter は沈黙します。 -- **ソースエンコーディング。** PEP 263 宣言は WHATWG ラベルと CPython コーデック別名テーブル(`latin-1`、`cp932`、`koi8_r`、...)で解決します。linter が表現できない稀な Python コーデックは、明示的な `MLC000` の「not supported by qual」通知とともにスキップされます — 対象の Python がそのファイルをデコードできない、という主張には決してなりません。 -- **duration はリテラルのみから導出します。** Manim の*デフォルト*に依存する play(`run_time` を一切書かない `self.play(m.animate.shift(RIGHT))`、`self.wait()`)の duration は unknown と報告され、フレーム数は数値の代わりに per-frame 表現になります(保守的: 欠落はしても捏造はしない)。リテラルな play レベルの `run_time` は play 全体の duration を正確に決定します — Scene ヘルパー内の play も呼び出しサイトごとに、呼び出しサイトが異なる(あるいは追跡不能な)mobject をパラメーター上の `.animate` ビルダーに渡す場合も含めて — 一方、*非リテラル*な `run_time`(や `**kwargs` splat)は、それが上書きするコンストラクタリテラルを正直に unknown へ広げます。 -- **サマリー由来の play は保守的です。** ヘルパーのインライン化が effect summary へフォールバックした場合(再帰、解決不能な呼び出し — カバレッジレポートの `helper calls summarized, not inlined` に計上)、そのヘルパーの play は `Maybe` 確度・開いた繰り返し回数のレコードとして現れます: `MLC104` のようなリテラル duration 検査はそこでも発火しますが、呼び出し元の状態に依存する判定はすべて degrade されたままです。 -- **`TracedPath` がコンストラクタで登録する updater はコスト専用です。** `TracedPath` が構築時に自身へ登録する updater はコスト目的ではモデル化されます(`MLP220` のスパン、lambda を渡した場合の hot context)が、ライフサイクル上の updater 登録ではありません: `TracedPath` 単独ではデフォルトの `wait()` はライフサイクルモデル上 dynamic にならず、バウンドメソッドの `traced_point_func` 本体は hot context として解析されません。 -- **意図的に保守的な沈黙。** 一部の検出はカタログの記述より狭く、推測するより沈黙します。`MLR106` は NaN / inf をリテラル形式でのみ見て、`float("nan")` 呼び出しは追いません。`MLD301` は `dt` パラメータを持たない updater についてのみ FPS 依存を証明します(宣言だけして未使用の `dt` は指摘しません)。`MLC113`/`MLC124` はドキュメント化された呼び出し形のみを認識します。`MLR102` は play された裸の builder の target が不変であることを解釈器が証明できる必要があります。`MLR105` は検証済みの Pango サブセットを検査します(裸の `&` は許容)。`MLD304` は ThreeDScene の fixed-object cleanup 分岐のみを実装しています。各ルールの正確な範囲は `qual explain ` が述べます。 -- **未実装。** レンダー済みベースラインに対する閾値較正、nightly のレンダー比較 CI。 +## 開発への参加 -## 開発 +[`DESIGN.md`](DESIGN.md)が意味モデル・ルールカタログ・公開contractの正典です。 +[`CONTRIBUTING.md`](CONTRIBUTING.md)にrepository構成、ルール追加、test gate、 +knowledge profile更新手順があります。 ```bash cargo fmt --check -cargo build cargo test --all-features cargo clippy --all-targets --all-features -- -D warnings ``` -4 つのゲートすべてが通る必要があります。 - -knowledge profile のメンテナンス: `sync_manim_knowledge` バイナリは Manim のチェックアウトを静的に読み、レビュー可能なプロファイル候補を生成し、同梱プロファイルのドリフトを検査します(矛盾があれば exit 1)— [src/knowledge/profiles/README.md](src/knowledge/profiles/README.md) を参照してください。来歴(provenance)は分離されています: `upstream_0_20` は**クリーンな** upstream ベースコミット `4d25c031`(working tree ではなく `git archive` 経由で読む)を記述し、`local_0_20_1_4d25c031` オーバーレイは兄弟フォークの working tree がその上に追加するものを記述します: - -```bash -# working tree(フォーク)— upstream に対しては情報提供のみ -cargo run --features dev-tools --bin sync_manim_knowledge -- --manim-root ../manim --diff -# クリーンな upstream ベース — 矛盾ゼロでなければならない -cargo run --features dev-tools --bin sync_manim_knowledge -- --manim-root ../manim --manim-ref 4d25c031 --diff -cargo test --test knowledge_drift -- --ignored # layer-9 ドリフトゲート(両方) -``` - -### リリース品質ゲート(DESIGN §11.4) - -リリースはさらに 3 つのゲートで守られます: - -```bash -# ラベル付きコーパスゲート — `cargo test` の中で自動的に実行される。 -# tests/corpus/manifest-v1.json が全コーパスケースの sha256 と -# 期待される診断の正確な内容(true positive と false-positive ガード)を -# ピン留めする。実 Manim example_scenes のスナップショットと -# 敵対的レビュープローブを含む。 -cargo test --test corpus_gate - -# ベンチマークゲート — 明示的に実行、release ビルド、静かなマシンで。 -# ピン留めされた 10k-LOC フィクスチャ(tests/corpus/benchmark_10kloc)で -# cold ≤ 2 s / warm hit ≤ 0.5 s / 20component中1fileのincremental ≤ 0.5 s / -# peak RSS < 300 MiB。 -# 閾値は benchmarks/reference-machine.json -# に一致するマシンでのみ assert され、それ以外では情報提供のみ。 -# gateはcoldがmiss、warmが検証済みhit、incrementalがpartial hitであることも証明する。 -# リファレンスマシンでの3回の中央値(2026-07-20): cold 0.422 s、 -# warm 0.006 s、incremental 0.171 s、peak RSS 246.5 MiB — すべてバジェット内。 -cargo test --release --test benchmark_gate -- --ignored benchmark - -# knowledge ドリフトゲート — 兄弟の Manim チェックアウトが必要。CI では -# schedule / dispatch 時に、ピン留めされたベースコミットの shallow clone に -# 対して実行される。 -cargo test --test knowledge_drift -- --ignored -``` - -コーパスケースを機械的に記録し直すことはありません: 不一致は [CONTRIBUTING.md](CONTRIBUTING.md#corpus-labeling) のラベリングプロトコルに基づく再判定(re-adjudication)を意味します。 - -リポジトリ構成、ルール追加の手順、すべての変更が守るべき不変条件は [CONTRIBUTING.md](CONTRIBUTING.md) を、パイプラインと事実レイヤーの概観は [docs/architecture.md](docs/architecture.md) を参照してください。`DESIGN.md` が正典であり、公開契約の変更は DESIGN.md・スキーマテスト・ルールドキュメントを同時に更新する必要があります。 - ## ライセンス -[MIT](LICENSE)。 +Qualは[MIT License](LICENSE)で配布します。binary distributionに含まれる +LGPL対象依存関係については[`THIRD-PARTY-LICENSES.md`](THIRD-PARTY-LICENSES.md)と +[`RELINKING.md`](RELINKING.md)を参照してください。 -プリビルド配布物には LGPL/GPL の本文、完全なロック済みソース、および -[再リンク手順](RELINKING.md)を同梱します。この素材が欠けると release gate は -公開を拒否します。依存ライセンスの詳細は -[THIRD-PARTY-LICENSES.md](THIRD-PARTY-LICENSES.md)を参照してください。 +Qualは独立プロジェクトです。Manim CommunityとRuffは本プロジェクトに関与せず、 +責任を負いません。 diff --git a/README.md b/README.md index c83efae..3e64d13 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,93 @@ # 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.** +**The Manim-aware linter. Catch bad renders before they happen.** -English | [日本語](README.ja.md) +[![Release](https://img.shields.io/github/v/release/Poietra/qual)](https://github.com/Poietra/qual/releases/latest) +[![PyPI](https://img.shields.io/pypi/v/qual-manim)](https://pypi.org/project/qual-manim/) +[![crates.io](https://img.shields.io/crates/v/qual)](https://crates.io/crates/qual) +[![CI](https://github.com/Poietra/qual/actions/workflows/ci.yml/badge.svg)](https://github.com/Poietra/qual/actions/workflows/ci.yml) +[![License](https://img.shields.io/github/license/Poietra/qual)](LICENSE) -`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. +[Documentation](https://poietra.github.io/qual/) · +[Rule catalog](https://poietra.github.io/qual/rules/) · +[日本語](README.ja.md) -## Example +Think Ruff for Manim scenes, with an understanding of `Scene.play`, mobject +lifecycles, updaters, Cairo/OpenGL behavior, and per-frame render cost. Qual +finds render-time crashes, silent visual bugs, and performance traps without +importing Manim or running your scene. -`scenes/demo.py`: +```bash +uv tool install qual-manim +qual check . +``` + +> Ruff and Pyright check the Python. Qual checks what Manim will do with it. + +## A general Python linter cannot see this ```python from manim import * -class TrackerDemo(Scene): +class Demo(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) + label = always_redraw( + lambda: MathTex(f"x={tracker.get_value():.2f}") + ) + self.add(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. +scene.py:9:21: 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. +scene.py:12: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()`. ``` -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. +`MLC102` aborts a render. `MLP226` can launch TeX work and create a new cached +asset on every frame. Qual finds both because it follows Manim's +lifecycle and distinguishes code that runs once from code that runs per frame. +It reports a number only when source and render-profile evidence prove it. + +## What Qual checks + +Qual 0.3 ships **92 implemented Manim-specific rules** in four families: + +| Family | Finds | +| --- | --- | +| **MLC — lifecycle and correctness** | Invalid animations, missing targets/state, updater mistakes, conflicting writes, and Scene-membership errors | +| **MLR — rendering** | Silent visual bugs in TeX, assets, geometry, ordering, cameras, and Cairo/OpenGL compatibility | +| **MLP — performance** | Per-frame construction, growing scene graphs, expensive callbacks, raster multipliers, and resource-key growth | +| **MLD — determinism and portability** | FPS-dependent motion, unseeded randomness, platform paths, fonts, and external state in frame callbacks | + +Every finding separates **severity** (`error`, `warning`, `info`) from +**confidence** (`certain`, `high`, `medium`, `low`). State-dependent rules +fire only with sufficient evidence. Unknown behavior stays `Unknown`; Qual +does not turn uncertainty into a high-confidence guess. + +[Browse all rules and their exact scope →](https://poietra.github.io/qual/rules/) + +## Install + +The PyPI package installs the native Rust executable. Qual does not require +Manim, LaTeX, or a Python runtime after installation. ```bash # Python tooling uv tool install qual-manim -# or: pipx 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: +are attached to each [GitHub release](https://github.com/Poietra/qual/releases/latest). ```bash # macOS / Linux @@ -123,693 +95,106 @@ 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: +## Use it locally and in CI ```bash -git clone https://github.com/Poietra/qual.git -cd qual -cargo install --path . +qual check . # rich terminal output +qual check . --format concise # one stable line per finding +qual check . --format github # GitHub Actions annotations +qual check . --format sarif # SARIF 2.1.0 +qual check . --fix # safe fixes only +qual cost scenes/demo.py # symbolic per-Scene render cost +qual coverage . # unresolved analysis frontiers +qual explain MLC102 # full documentation for one rule ``` -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. +Exit codes are CI-friendly: `0` means the failure threshold was not reached, +`1` means it was reached, and `2` means an input, configuration, or internal +error. -## Quickstart +Existing projects can record current findings and fail only on new ones: ```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 +qual check . --write-baseline .qual-baseline.json +qual check . --baseline .qual-baseline.json ``` -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 +Configuration lives in `[tool.qual]` in `pyproject.toml`. Named profiles model +the renderer, platform, resolution, and FPS used by preview and production +renders. See the [configuration guide](https://poietra.github.io/qual/guides/configuration/) +for selectors, suppressions, profiles, and `manim.cfg` precedence. -Adopt the linter on an existing project without fixing everything first: +## Works alongside Ruff and Pyright -```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 +Qual deliberately does not reimplement general Python linting or type +checking: ```bash -qual check . --fix # apply SAFE fixes only -qual check . --fix --unsafe-fixes # also apply UNSAFE fixes +ruff check . +pyright +qual check . ``` -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. +- Ruff handles style, imports, and general Python lint rules. +- Pyright handles Python types. +- Qual handles Manim lifecycle, rendering semantics, and render cost. -```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) -``` +Qual is a standalone analyzer because Manim bugs often span statements, +helpers, Scene membership changes, animation setup/cleanup, and render +profiles. It is not a Ruff plugin and does not imply affiliation with Ruff. -## Cost command +## Documentation and machine APIs -`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: +The [documentation site](https://poietra.github.io/qual/) provides searchable +guides and references for: -```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: +- installation, configuration, CI, baselines, suppressions, and fixes; +- all 92 rules, including evidence and near-miss behavior; +- `qual cost` and `qual coverage`; +- architecture, research evidence, and contributor guidance; +- versioned machine interfaces and JSON schemas. -```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 -``` +Qual's public integration surface is CLI plus versioned JSON—not a network +service or public Rust library: -## 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 +```bash +qual check . --format json +qual static-facts . +qual change-impact --before old-tree --after new-tree +qual source-bridge . --request request.json ``` -Or upload SARIF so findings appear in the GitHub code-scanning UI: +Start with the [machine API overview](https://poietra.github.io/qual/reference/machine-api/) +and validate output against the checked-in schemas. -```yaml - - run: qual check . --format sarif > qual.sarif - continue-on-error: true - - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: qual.sarif -``` +## Safety and scope -## Pre-execution admission checks +- Qual never imports or executes Manim, plugins, or analyzed user code. +- The current knowledge profiles target **Manim Community 0.20**. +- Asset checks inspect the linting machine's filesystem using Manim's modeled + search order; environment-dependent evidence is marked as such. +- Dynamic Python is handled conservatively. Use `qual coverage` to inspect + what could not be resolved. +- Python style and general type errors remain the job of Ruff and Pyright. -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. +## Contributing -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 `. - -## 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 ` - states each rule's exact scope. -- **Not yet implemented.** Threshold calibration against rendered baselines; - a nightly render-comparison CI. - -## Development +[`DESIGN.md`](DESIGN.md) is the authoritative semantic model, rule catalog, +and rollout specification. [`CONTRIBUTING.md`](CONTRIBUTING.md) explains the +repository layout, adding a rule, test gates, and knowledge-profile updates. ```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 distributed under the [MIT License](LICENSE). Binary distributions +also contain relinkable LGPL-covered dependencies; see +[`THIRD-PARTY-LICENSES.md`](THIRD-PARTY-LICENSES.md) and +[`RELINKING.md`](RELINKING.md). -`qual` is an independent project. Manim Community is not affiliated -with it and does not endorse it. +Qual is an independent project. Manim Community and Ruff are not affiliated +with or responsible for it. diff --git a/docs/architecture.md b/docs/architecture.md index b54b8be..b68b419 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ This document is the contributor's map of qual: how a Python source tree becomes diagnostics, which fact layer owns what, and where each piece -lives. [`DESIGN.md`](../DESIGN.md) is the authoritative specification (the +lives. [`DESIGN.md`](https://github.com/Poietra/qual/blob/main/DESIGN.md) is the authoritative specification (the section numbers below refer to it); this page is the guided tour. ## The pipeline @@ -90,7 +90,7 @@ semantics — introducers/removers, auto-add, constructor signatures, `returns_self`, renderer notes, fork capabilities. Nothing in the analyzer hardcodes "what `FadeIn` does"; if the profile does not say it, the analyzer does not know it (absent facts mean *not curated*, never -*false*). See [the profiles README](../src/knowledge/profiles/README.md) +*false*). See [the profiles README](https://github.com/Poietra/qual/blob/main/src/knowledge/profiles/README.md) for provenance: - `upstream_0_20` describes the **clean** upstream base commit @@ -232,7 +232,7 @@ reason-carrying unknown values; it includes Scenes, reachable objects, plays/animations, updaters, play-boundary membership/render order, renderer risks, and coverage frontiers. See [`docs/rfcs/0001-static-facts-v0.md`](rfcs/0001-static-facts-v0.md) and -[`schemas/static-facts-v0.json`](../schemas/static-facts-v0.json). +[`schemas/static-facts-v0.json`](https://github.com/Poietra/qual/blob/main/schemas/static-facts-v0.json). `qual static-facts [PATH...]` reads each source into one immutable raw byte snapshot, runs the frontend and lifecycle fact layers independently of @@ -323,6 +323,6 @@ target could not be proven frame-varying, the rule would stay silent. 12. Diagnostic order and serialized output are deterministic — byte-stable for the same input. -See [CONTRIBUTING.md](../CONTRIBUTING.md) for the workflow built on top +See [CONTRIBUTING.md](https://github.com/Poietra/qual/blob/main/CONTRIBUTING.md) for the workflow built on top of these: how to add a rule, the corpus labeling protocol, and the knowledge-profile review rules. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..4c5c46c --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,91 @@ +# Installation and first check + +Qual is distributed as a native Rust executable. The PyPI package installs +that executable; using it does not require Manim, LaTeX, or a Python runtime. + +## Install + +=== "uv" + + ```bash + uv tool install qual-manim + ``` + +=== "pipx" + + ```bash + pipx install qual-manim + ``` + +=== "Cargo" + + ```bash + cargo install qual --locked + ``` + +=== "Standalone installer" + + ```bash + curl --proto '=https' --tlsv1.2 -LsSf \ + https://github.com/Poietra/qual/releases/latest/download/qual-installer.sh | sh + ``` + +Checksummed archives for Linux, macOS, and Windows are available from the +[latest GitHub release](https://github.com/Poietra/qual/releases/latest). + +## Run your first check + +From a Manim project: + +```bash +qual check . +``` + +In a terminal, Qual shows source context, an explanation, and a summary. When +stdout is redirected it uses a stable one-line format. Select a format +explicitly when another tool consumes the result: + +```bash +qual check . --format concise +qual check . --format json +qual check . --format sarif +qual check . --format github +``` + +Exit codes are designed for CI: + +| Code | Meaning | +| --- | --- | +| `0` | No reported diagnostic reaches `fail-level` | +| `1` | At least one reported diagnostic reaches `fail-level` | +| `2` | Command-line, configuration, input, or internal error | + +## Understand a finding + +```text +✖ MLC102 scenes/demo.py:12:19 + + `square.shift(...)` returns the mobject itself, not an Animation. + Use `square.animate.shift(...)` inside `Scene.play()`. +``` + +The rule ID is stable public vocabulary. Open its full documentation with: + +```bash +qual explain MLC102 +``` + +Every finding has two independent dimensions: + +- **severity** — `error`, `warning`, or `info`; +- **confidence** — `certain`, `high`, `medium`, or `low`. + +The default confidence threshold is conservative. If a value cannot be +resolved, Qual records `Unknown` and avoids upgrading the uncertainty into a +high-confidence error. + +## Next steps + +- Set renderer, FPS, resolution, and policy in [configuration](guides/configuration.md). +- Introduce Qual gradually with [baselines and suppressions](guides/adoption.md). +- See all supported checks in the [rule catalog](rules/README.md). diff --git a/docs/guides/adoption.md b/docs/guides/adoption.md new file mode 100644 index 0000000..d7c2adf --- /dev/null +++ b/docs/guides/adoption.md @@ -0,0 +1,90 @@ +# CI, baselines, and fixes + +## Suppress one intentional finding + +```python +self.play(square.shift(RIGHT)) # qual: ignore[MLC102] + +# qual: ignore[MLP201] +label = always_redraw(...) + +# qual: file-ignore[MLP] +``` + +An end-of-line suppression covers its statement. A standalone suppression +covers the next statement. File-wide suppressions must be in the file header. +Unknown rule IDs do not suppress anything and produce `MLC001`. + +For directories or generated fixtures, use `per-file-ignores`: + +```toml +[tool.qual] +per-file-ignores = { "tests/fixtures/**" = ["MLP", "MLD"] } +``` + +## Adopt an existing project with a baseline + +```bash +qual check . --write-baseline .qual-baseline.json +qual check . --baseline .qual-baseline.json +``` + +The second command reports only new findings. Baseline fingerprints use the +rule, relative path, qualified Scene, and surrounding token structure rather +than a line number, so unrelated edits do not invalidate the whole file. + +## Apply fixes + +```bash +qual check . --fix +qual check . --fix --unsafe-fixes +``` + +`--fix` applies only behavior-preserving edits. `--unsafe-fixes` additionally +allows suggestions that can change runtime semantics. Every changed file is +parsed again; a failing edit is rolled back. + +## GitHub Actions + +Install the released binary and emit annotations on the pull request diff: + +```yaml +name: qual + +on: + pull_request: + push: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pipx install qual-manim + - run: qual check . --format github +``` + +For GitHub code scanning, upload SARIF: + +```yaml + - run: qual check . --format sarif > qual.sarif + continue-on-error: true + - uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: qual.sarif +``` + +## Admission checks for render services + +Because analysis never imports or executes submitted code, a rendering +service can run Qual before allocating render capacity. Start by observing +findings against real outcomes; if blocking is appropriate, use the strictest +confidence threshold: + +```bash +qual check "$SCENE_DIR" --format json \ + --min-confidence certain --fail-level error +``` + +Exit code `2` indicates an analysis/configuration failure and must not be +treated as a rejected scene. diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md new file mode 100644 index 0000000..6ac45c4 --- /dev/null +++ b/docs/guides/configuration.md @@ -0,0 +1,106 @@ +# Configuration + +Qual searches upward from the checked path for `pyproject.toml`. Base policy +lives under `[tool.qual]`; named render environments use +`[[tool.qual.profile]]`. + +```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 + +Settings resolve in this order, from strongest to weakest: + +```text +CLI > selected profile > pyproject base > manim.cfg > builtin defaults +``` + +`qual config` prints the final values and an `enforcement` section showing +which settings affect analysis and which are informational. + +## Profiles + +Profiles let CI evaluate the same source under its real render targets: + +```toml +default-profile = "preview" + +[[tool.qual.profile]] +name = "preview" +renderer = "cairo" +pixel-width = 854 +pixel-height = 480 +frame-rate = 30 + +[[tool.qual.profile]] +name = "production" +renderer = "opengl" +pixel-width = 3840 +pixel-height = 2160 +frame-rate = 60 +``` + +```bash +qual check . --profile production +qual check . --profile all +``` + +With `--profile all`, Qual merges diagnostics with the same evidence and lists +the profiles to which each one applies. + +## Command-line policy + +Common overrides: + +```bash +qual check . --select MLC,MLR +qual check . --ignore MLP +qual check . --min-confidence certain +qual check . --fail-level error +qual check . --renderer cairo --fps 60 --resolution 1920x1080 +``` + +Unknown keys, selectors, profiles, and invalid numeric values are hard +configuration errors. Qual does not silently ignore policy it cannot enforce. + +## Knowledge profiles + +`knowledge-profile` selects a versioned static model of Manim semantics. The +normal profile for Manim Community 0.20 is: + +```toml +[tool.qual] +knowledge-profile = "upstream_0_20" +``` + +The local optimized-fork overlay is intentionally separate: + +```toml +[tool.qual] +knowledge-profile = "local_0_20_1_4d25c031" +``` + +Qual never imports the installed Manim package to discover behavior at lint +time. Version-sensitive facts come from the selected reviewed profile. diff --git a/docs/guides/cost-and-coverage.md b/docs/guides/cost-and-coverage.md new file mode 100644 index 0000000..9de5b92 --- /dev/null +++ b/docs/guides/cost-and-coverage.md @@ -0,0 +1,59 @@ +# Cost and coverage + +Diagnostics answer “what should I inspect?” The cost and coverage commands +answer two related questions: “why might this render be expensive?” and “what +could the analyzer not prove?” + +## Symbolic render cost + +```bash +qual cost scenes/demo.py +``` + +The report groups evidence by Scene: + +```text +profiles: production (cairo, 1920x1080, 60 fps) + +scene scenes.demo.TrackerDemo (scenes/demo.py) + plays: + scenes/demo.py:13:9 play duration 8 s -> frames ~480 + hot contexts: + scenes/demo.py:9:31 entry always_redraw; factors frames + per-frame constructions: + scenes/demo.py:9:39 MathTex construction x at least ~480 invocations + resource-key growth: + scenes/demo.py:9:39 MathTex distinct cache keys: at least ~480 +``` + +Qual tracks dimensions such as frames, family members, points, curves, +pixels, and distinct resource keys. It does not turn unknown values into a +fabricated wall-clock estimate. An unresolved duration remains `per-frame` or +`unknown`. + +## Analysis coverage + +A clean lint run can mean either “no findings” or “some relevant behavior was +not statically resolvable.” Inspect that boundary explicitly: + +```bash +qual coverage . +qual coverage . --format json +qual check . --analysis-summary +``` + +Coverage reports include parse success, unresolved imports and calls, unknown +play durations, unknown animation targets, and helper-summary fallbacks. The +counts describe computed analysis facts, not statistical estimates. + +## Cache behavior + +Normal checks keep a disposable SQLite cache in: + +```text +.qual-cache/cache-v2.sqlite3 +``` + +Add `.qual-cache/` to the analyzed project's ignore file. The cache is never +required for correctness; corruption or I/O failure falls back to analysis. +Use `--no-cache` to disable all cache reads, writes, and directory creation. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..6688592 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,87 @@ +--- +title: Qual — the Manim-aware linter +description: Catch render-time errors, silent visual bugs, and per-frame performance traps before you render. +--- + +
+ +# Lint Manim before you render + +**Qual is the Manim-aware linter.** It catches render-time errors, silent +visual bugs, and per-frame performance traps without importing Manim or +running your scene. + +[Get started](getting-started.md){ .md-button .md-button--primary } +[Browse all 92 rules](rules/README.md){ .md-button } + +
+ +```bash +uv tool install qual-manim +qual check . +``` + +Think Ruff for Manim scenes, with an understanding of `Scene.play`, mobject +lifecycles, updaters, Cairo/OpenGL behavior, and render cost. Ruff and Pyright +still check Python itself; Qual checks what Manim will do with it. + +
+ +- **Catch crashes early** + + Invalid `Scene.play` arguments, missing targets or saved state, invalid + callback signatures, and other errors that otherwise surface during a + render. + +- **Find the wrong picture** + + Lifecycle, renderer, ordering, updater, TeX, asset, and geometry mistakes + that can finish successfully while producing the wrong result. + +- **Explain render cost** + + Per-frame construction, growing scene graphs, expensive callbacks, and + resource-key growth with conservative multiplicity evidence. + +- **Stay safe by default** + + Qual never imports or executes Manim, plugins, or analyzed user code. An + unresolved value becomes `Unknown`, not a high-confidence guess. + +
+ +## A general Python linter cannot see this + +```python +self.play(square.shift(RIGHT)) +# ^ MLC102: shift() returns the mobject, not an Animation +``` + +```python +label = always_redraw( + lambda: MathTex(f"x={tracker.get_value():.2f}") +) +# MLP226: a frame-varying TeX key may create one disk asset per rendered frame +``` + +Qual resolves imports and aliases, follows project-local helpers, models Scene +membership and animation cleanup, and distinguishes code that runs once from +code that runs every frame. It reports a number only when the source and +selected render profile prove it. + +## Choose your path + +- **Scene authors:** start with [installation and your first check](getting-started.md), + then browse the [rule catalog](rules/README.md). +- **Existing projects:** use [baselines, suppressions, and safe fixes](guides/adoption.md) + for gradual adoption. +- **Performance work:** use the [cost and coverage reports](guides/cost-and-coverage.md). +- **Tool builders:** use the versioned [machine APIs](reference/machine-api.md) + and [JSON schemas](reference/schemas.md). +- **Contributors:** read the [architecture](architecture.md) and authoritative + [design specification](https://github.com/Poietra/qual/blob/main/DESIGN.md). + +!!! info "Current support" + + Qual 0.3 targets Manim Community 0.20. It ships 92 implemented rules and + native binaries for Linux, macOS, and Windows. diff --git a/docs/reference/cli.md b/docs/reference/cli.md new file mode 100644 index 0000000..c0b1a6a --- /dev/null +++ b/docs/reference/cli.md @@ -0,0 +1,46 @@ +# CLI reference + +The executable and command are both named `qual`. + +## Commands + +| Command | Purpose | +| --- | --- | +| `qual check [PATH...]` | Run diagnostics and optional fixes | +| `qual explain RULE` | Print the full documentation for one rule | +| `qual rules` | List rule IDs, status, severity, and confidence | +| `qual config` | Print resolved configuration and enforcement status | +| `qual cost PATH` | Explain symbolic render cost per Scene | +| `qual coverage [PATH...]` | Report unresolved analysis frontiers | +| `qual static-facts [PATH...]` | Emit StaticFacts v0 JSON | +| `qual change-impact --before OLD --after NEW` | Emit conservative source-change impact | +| `qual source-bridge PATH --request REQUEST.json` | Validate bounded source-patch candidates | + +Run `qual --help` for the exhaustive option list shipped by your +installed version. + +## Common `check` options + +| Option | Purpose | +| --- | --- | +| `--select`, `--ignore` | Select rule IDs or families | +| `--min-confidence` | Minimum confidence to display | +| `--fail-level` | Lowest severity that produces exit code 1 | +| `--profile` | Select one configured render profile or `all` | +| `--renderer`, `--fps`, `--resolution` | Override render facts | +| `--format` | `rich`, `concise`, `full`, `json`, `sarif`, or `github` | +| `--fix`, `--unsafe-fixes` | Apply safe or explicitly unsafe edits | +| `--baseline`, `--write-baseline` | Compare with or create a baseline | +| `--statistics` | Print diagnostic counts | +| `--analysis-summary` | Print coverage after diagnostics | +| `--no-cache` | Disable analysis-cache filesystem activity | + +## Stable automation contracts + +- Exit `0`: no finding reaches the failure threshold. +- Exit `1`: at least one finding reaches the failure threshold. +- Exit `2`: command-line, configuration, input, or internal error. +- JSON and SARIF go to stdout; logging and analysis summaries stay separate. +- Output is deterministically sorted for the same source and semantic config. + +For serialized field definitions, use the [JSON schema index](schemas.md). diff --git a/docs/reference/machine-api.md b/docs/reference/machine-api.md new file mode 100644 index 0000000..f32fc5a --- /dev/null +++ b/docs/reference/machine-api.md @@ -0,0 +1,66 @@ +# Machine APIs + +Qual is a command-line application, not a network service or a public Rust +library. Its supported integration surface is a set of versioned CLI and JSON +contracts. An integration should invoke the executable, check the exit code, +and validate JSON against the matching schema version. + +## Diagnostics + +```bash +qual check . --format json > diagnostics.json +``` + +Use diagnostics when a person or CI system needs actionable findings. Rule +selection, suppressions, confidence thresholds, and baselines apply. + +[Diagnostics schema](schemas.md#diagnostics-and-operational-output) + +## StaticFacts v0 + +```bash +qual static-facts . > static-facts.json +``` + +StaticFacts exposes a stable projection of Scenes, reachable objects, plays, +animations, updaters, membership/order boundaries, renderer risks, and +reason-carrying unknown frontiers. It is intentionally independent of rule +selection and suppression. + +[Contract](../rfcs/0001-static-facts-v0.md) · +[Schema](schemas.md#semantic-toolchain) + +## ChangeImpact v0 + +```bash +qual change-impact --before old-tree --after new-tree > impact.json +``` + +ChangeImpact analyzes both snapshots and returns conservative Scene, play, +and object candidates affected through the semantic dependency graph. It +retains deleted edges from the base snapshot rather than guessing renames. + +[Contract](../rfcs/0003-change-impact-v0.md) · +[Schema](schemas.md#semantic-toolchain) + +## SourceBridge v0 + +```bash +qual source-bridge . --request request.json > candidates.json +``` + +SourceBridge generates a deliberately bounded set of hash-guarded patch +candidates, applies each one only in memory, reanalyzes the source, and emits +accepted or rejected rematching results. The command never writes the +analyzed project. + +[Contract](../rfcs/0004-source-bridge-v0.md) · +[Request and response schemas](schemas.md#semantic-toolchain) + +## Compatibility rules + +1. Read `schema_version` before interpreting a document. +2. Treat IDs as snapshot-scoped unless the contract explicitly says otherwise. +3. Preserve reason-carrying `Unknown` values; do not coerce them to false. +4. Do not deserialize undocumented internal Rust types. +5. Pin a Qual version when consuming a contract in production. diff --git a/docs/reference/schemas.md b/docs/reference/schemas.md new file mode 100644 index 0000000..da8b79f --- /dev/null +++ b/docs/reference/schemas.md @@ -0,0 +1,37 @@ +# JSON schemas + +The checked-in schemas are the machine-readable source of truth. RFCs explain +semantics and invariants; schemas define accepted serialized structure. + +## Diagnostics and operational output + +- [`diagnostics-v1.json`](https://github.com/Poietra/qual/blob/main/schemas/diagnostics-v1.json) +- [`baseline-v1.json`](https://github.com/Poietra/qual/blob/main/schemas/baseline-v1.json) + +## Semantic toolchain + +- [`static-facts-v0.json`](https://github.com/Poietra/qual/blob/main/schemas/static-facts-v0.json) +- [`change-impact-v0.json`](https://github.com/Poietra/qual/blob/main/schemas/change-impact-v0.json) +- [`source-bridge-request-v0.json`](https://github.com/Poietra/qual/blob/main/schemas/source-bridge-request-v0.json) +- [`source-bridge-v0.json`](https://github.com/Poietra/qual/blob/main/schemas/source-bridge-v0.json) + +## Validation + +Schemas use JSON Schema Draft 2020-12. Consumers should reject documents with +an unsupported `schema_version` rather than trying to infer compatibility. + +Example with Python's `jsonschema` package: + +```python +import json +from pathlib import Path + +from jsonschema import Draft202012Validator + +schema = json.loads(Path("schemas/diagnostics-v1.json").read_text()) +document = json.loads(Path("diagnostics.json").read_text()) +Draft202012Validator(schema).validate(document) +``` + +Qual's own tests validate representative producer output against these +schemas and require deterministic serialization for identical inputs. diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..d3504c7 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1 @@ +mkdocs-material==9.7.7 diff --git a/docs/rules/README.md b/docs/rules/README.md index cc92348..75d0c79 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -2,7 +2,7 @@ qual defines 92 rule IDs in four families, and all 92 are **implemented**. The authoritative catalog definition is -[`DESIGN.md`](../../DESIGN.md) section 7. +[`DESIGN.md`](https://github.com/Poietra/qual/blob/main/DESIGN.md) section 7. One implemented rule is opt-in: `MLP225` has `default_enabled: false` and the capabilities `cost-report` + `local-fork-overlay` — its home is the diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..71a1301 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,27 @@ +.md-typeset h1, +.md-typeset h2, +.md-typeset h3 { + text-wrap: balance; +} + +.md-typeset p, +.md-typeset li { + text-wrap: pretty; +} + +.md-typeset .qual-hero { + margin: 1.5rem 0 2rem; + max-width: 48rem; +} + +.md-typeset .qual-hero h1 { + margin-bottom: 0.5rem; +} + +.md-typeset .qual-hero p { + font-size: 1.1rem; +} + +.md-typeset .grid.cards > ul > li { + border-radius: 0.25rem; +} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..ecd30b8 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,88 @@ +# yaml-language-server: $schema=https://squidfunk.github.io/mkdocs-material/schema.json +site_name: Qual +site_description: The Manim-aware linter for render-time errors, visual bugs, and per-frame performance traps. +site_url: https://poietra.github.io/qual/ +repo_url: https://github.com/Poietra/qual +repo_name: Poietra/qual +edit_uri: edit/main/docs/ + +theme: + name: material + language: en + icon: + logo: material/motion-play + repo: fontawesome/brands/github + font: false + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.tabs + - navigation.sections + - navigation.top + - navigation.footer + - search.suggest + - search.highlight + - content.code.copy + - content.tabs.link + - toc.follow + +plugins: + - search + +markdown_extensions: + - admonition + - attr_list + - md_in_html + - tables + - toc: + permalink: true + - pymdownx.details + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + +extra_css: + - stylesheets/extra.css + +nav: + - Home: index.md + - Get started: + - Installation and first check: getting-started.md + - Configuration: guides/configuration.md + - CI, baselines, and fixes: guides/adoption.md + - Cost and coverage: guides/cost-and-coverage.md + - Rules: + - Rule catalog: rules/README.md + - Reference: + - CLI: reference/cli.md + - Machine APIs: reference/machine-api.md + - JSON schemas: reference/schemas.md + - StaticFacts v0: rfcs/0001-static-facts-v0.md + - Dependency graph v0: rfcs/0002-semantic-dependency-graph-v0.md + - ChangeImpact v0: rfcs/0003-change-impact-v0.md + - SourceBridge v0: rfcs/0004-source-bridge-v0.md + - Internals: + - Architecture: architecture.md + - Performance evidence: research/perf-evidence.md + - Corpus evidence: research/corpus-evidence.md + - Releasing: releasing.md + - Design specification: https://github.com/Poietra/qual/blob/main/DESIGN.md + +copyright: Copyright © 2026 Poietra · MIT License diff --git a/pyproject.toml b/pyproject.toml index 1de121f..7628bd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "maturin" [project] name = "qual-manim" dynamic = ["version"] -description = "Static lifecycle and performance analysis for Manim scenes" +description = "A Manim-aware linter for render-time errors, visual bugs, and per-frame performance traps" readme = "README.md" requires-python = ">=3.8" license = "MIT" @@ -28,7 +28,8 @@ classifiers = [ ] [project.urls] -Homepage = "https://github.com/Poietra/qual" +Homepage = "https://poietra.github.io/qual/" +Documentation = "https://poietra.github.io/qual/" Repository = "https://github.com/Poietra/qual" Changelog = "https://github.com/Poietra/qual/blob/main/CHANGELOG.md" diff --git a/scripts/check_release.py b/scripts/check_release.py index c674ec7..99bbe57 100644 --- a/scripts/check_release.py +++ b/scripts/check_release.py @@ -14,6 +14,7 @@ PACKAGE_NAME = "qual" PYPI_PROJECT_NAME = "qual-manim" REPOSITORY_URL = "https://github.com/Poietra/qual" +DOCUMENTATION_URL = "https://poietra.github.io/qual/" SEMVER = re.compile( r"^(?P0|[1-9]\d*)\." r"(?P0|[1-9]\d*)\." @@ -75,11 +76,11 @@ def main() -> None: fail(f"Cargo package name must remain {PACKAGE_NAME}") if package.get("default-run") != PACKAGE_NAME: fail(f"default Cargo binary must remain {PACKAGE_NAME}") - for field in ("repository", "homepage"): - if package.get(field) != REPOSITORY_URL: - fail(f"Cargo {field} must remain {REPOSITORY_URL}") - if package.get("documentation") != f"{REPOSITORY_URL}#readme": - fail("Cargo documentation URL must point to the Qual README") + if package.get("repository") != REPOSITORY_URL: + fail(f"Cargo repository must remain {REPOSITORY_URL}") + for field in ("homepage", "documentation"): + if package.get(field) != DOCUMENTATION_URL: + fail(f"Cargo {field} must point to {DOCUMENTATION_URL}") bins = cargo.get("bin") if not isinstance(bins, list) or not any( isinstance(binary, dict) @@ -137,8 +138,12 @@ def main() -> None: urls = project.get("urls") if not isinstance(urls, dict): fail("pyproject.toml has no [project.urls] table") - if urls.get("Homepage") != REPOSITORY_URL or urls.get("Repository") != REPOSITORY_URL: - fail("PyPI homepage and repository must point to the Qual repository") + if urls.get("Homepage") != DOCUMENTATION_URL: + fail(f"PyPI homepage must point to {DOCUMENTATION_URL}") + if urls.get("Documentation") != DOCUMENTATION_URL: + fail(f"PyPI documentation must point to {DOCUMENTATION_URL}") + if urls.get("Repository") != REPOSITORY_URL: + fail("PyPI repository must point to the Qual repository") if urls.get("Changelog") != f"{REPOSITORY_URL}/blob/main/CHANGELOG.md": fail("PyPI changelog URL must point to the Qual changelog") if project.get("dynamic") != ["version"] or "version" in project: diff --git a/src/cli.rs b/src/cli.rs index d7e4c51..67bf8c5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -99,7 +99,7 @@ impl std::str::FromStr for ColorMode { #[command( name = "qual", version, - about = "Static lifecycle and performance analysis for Manim scenes" + about = "A Manim-aware linter for render-time errors, visual bugs, and per-frame performance traps" )] pub struct Cli { /// Subcommand to run. diff --git a/src/lib.rs b/src/lib.rs index b71e808..67d822e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ -//! Static analysis for Manim source projects. +//! Manim-aware static analysis for render-time errors, visual bugs, and +//! per-frame performance traps. //! //! The analyzer never imports Manim and never executes analyzed user code.