[importlinter]
root_packages =
    saitenka
    saitenka_card
    saitenka_subtitles
    saitenka_tokenize
    saitenka_wordstate
include_external_packages = True
exclude_type_checking_imports = True

[importlinter:contract:subtitle-package-is-independent]
name = The subtitle package never imports the application it was extracted from
type = forbidden
# Was an enumerated list of app/mpvio/render, which is the weaker claim: it named the edges that
# existed rather than the ones that must not. The last real edge was libass_backend recording into
# otel_metrics' module globals, which `configure` rebinds — a library bound to this application's
# telemetry lifecycle. It takes a sink now, so the whole of `saitenka` can be forbidden.
source_modules =
    saitenka_subtitles
forbidden_modules =
    saitenka

[importlinter:contract:no-cycles]
name = No import cycles among Saitenka's top-level packages
type = acyclic_siblings
ancestors =
    saitenka
# Fully enforced — zero grandfathered cycles remain (#30, burned down 2026-08-08). Every edge once
# ratcheted here is gone: mpvio<->app (otel_metrics relocated to its leaf layer) and
# render.document->sc.model / otel_export->telemetry (moved into TYPE_CHECKING) earlier; then, closing
# #30, draw.chip->render.layout (chip moved into render/, since it consumes layout), dictdb<->yomitan_import
# and dictdb<->wordlists (the pure zip-parse helpers classify_zip/read_json_bank/_title_of extracted to the
# leaf app/bankreader.py), doctor/report<->crashlog (crash_dir() moved to the leaf app/paths.py), and
# controller<->miner (miner imports SKIP_POS from the tokenizer, not controller).
# No ignore_imports — the contract is exhaustive. Never add one; only sever edges.

[importlinter:contract:layers]
name = Package layering — a lower layer must not import a higher one
# Positive DIRECTION contract (no-cycles only forbids round-trips; this forbids a backward edge even
# without a cycle). High→low follows the data flow: app orchestrates → mpvio bridges → panel/raster
# compose → draw rasterises → render lays out → sc models the input → model/version are leaf values.
# TYPE_CHECKING edges don't count (exclude_type_checking_imports). Ruff analyze counts typing-only
# edges, so it can show phantom backedges this contract does not: import-linter is the SSOT.
type = layers
exhaustive = False
layers =
    saitenka.app
    saitenka.mpvio
    saitenka.panel
    saitenka.raster
    saitenka.render
    saitenka.draw
    saitenka.sc
    saitenka.model

[importlinter:contract:pil-agnostic-core]
name = sc/ and model.py stay PIL-agnostic
type = forbidden
# Direct imports only (allow_indirect_imports): sc/ legitimately imports saitenka.render for the
# Inline type (see the no-cycles ratchet above) and render/draw pull in PIL themselves — a
# transitive check would flag that pre-existing, accepted design. This preserves the original
# test_layering.py semantics (a literal `import PIL` line), just without the hand-parsed
# TYPE_CHECKING logic (exclude_type_checking_imports does that for free).
allow_indirect_imports = True
source_modules =
    saitenka.sc
    saitenka.model
forbidden_modules =
    PIL

[importlinter:contract:pil-app-allowlist]
name = app/ imports PIL only via raster/ or the migration allowlist
type = forbidden
allow_indirect_imports = True
source_modules =
    saitenka.app
forbidden_modules =
    PIL
# subtitles/toast/preview migrate to the raster protocol opportunistically, later
# (see tests/test_layering.py docstring, pre-existing allowlist).
ignore_imports =
    saitenka.app.subtitles -> PIL
    saitenka.app.toast -> PIL
    saitenka.app.features.preview.card_preview -> PIL
    saitenka.app.features.preview.miner_ui -> PIL
    saitenka.app.features.mining.preview_access -> PIL

[importlinter:contract:feature-packages-acyclic]
name = Feature packages do not form runtime dependency cycles
type = acyclic_siblings
ancestors =
    saitenka.app.features

[importlinter:contract:features-do-not-own-session]
name = Feature packages do not import the session composition root
type = forbidden
allow_indirect_imports = True
source_modules =
    saitenka.app.features
forbidden_modules =
    saitenka.app.session

[importlinter:contract:interaction-is-lower-level]
name = Shared interaction primitives do not import features or session composition
type = forbidden
allow_indirect_imports = True
source_modules =
    saitenka.app.interaction
forbidden_modules =
    saitenka.app.features
    saitenka.app.session

[importlinter:contract:gpl-chokepoint]
name = only app.dictionary / app.doctor may import the GPL deinflect add-on
type = forbidden
source_modules =
    saitenka
forbidden_modules =
    saitenka_deinflect
ignore_imports =
    saitenka.app.dictionary -> saitenka_deinflect
    saitenka.app.doctor -> saitenka_deinflect

[importlinter:contract:layout-engine-chokepoint]
name = only render.layout_backend may import the optional taffylite layout engine
type = forbidden
source_modules =
    saitenka
forbidden_modules =
    taffylite
ignore_imports =
    saitenka.render.layout_backend -> taffylite

[importlinter:contract:svg-images-chokepoint]
name = only app.dictdb may import the optional resvg-py SVG rasterizer
type = forbidden
source_modules =
    saitenka
forbidden_modules =
    resvg_py
    resvglite
ignore_imports =
    saitenka.app.dictdb -> resvg_py

[importlinter:contract:libasslite-boundary]
name = production code cannot import the experimental libass binding yet
type = forbidden
# `saitenka_subtitles` is named too: the one module that would reach for it moved there, and a
# contract scoped to the package it left would have kept passing while covering nothing.
source_modules =
    saitenka
    saitenka_subtitles
forbidden_modules =
    libasslite

[importlinter:contract:tokenize-package-is-independent]
name = The tokenize package never imports the application it was extracted from
type = forbidden
# The retirement criterion, as a gate: A is retired when no tokenization behaviour is reachable from
# `saitenka` except through this package, and the package owes `saitenka` nothing back.
source_modules =
    saitenka_tokenize
forbidden_modules =
    saitenka

[importlinter:contract:word-state-package-is-independent]
name = The word-state package never imports the application it was extracted from
type = forbidden
# The retirement criterion as a gate, and the severance too: fsrs.diff_pill built a panel.Freq and
# the Scorer held a Palette, so this edge used to exist in two places. A palette import would fail
# here even function-locally, which is the form the layering contract could never see.
source_modules =
    saitenka_wordstate
forbidden_modules =
    saitenka

[importlinter:contract:word-state-reads-no-dictionary]
name = The word-state package does not read a dictionary
type = forbidden
# wordlists was two capabilities in one file, split by data source: term_meta views (app.dict_meta)
# and the user's collection (this package). Frequency and JLPT arrive as protocols, so a concrete
# table would be a dependency inversion, not a convenience.
source_modules =
    saitenka_wordstate
forbidden_modules =
    saitenka_dict

[importlinter:contract:mpv-transport-constructs-no-runtime-value]
name = The mpv transport constructs no session-runtime value
# The pair to runtime-core-independent: that one stops the core reaching down, this one stops the
# transport reaching up. osd.py used to build EffectFinished/EffectOutcome behind function-local
# imports — a real edge kept out of the module-level graph. Surface commands now cross as a command
# tuple and a bool, and the app layer that owns the transaction mints the completion.
#
# Scoped to RUNTIME imports on purpose, hence the wording: exclude_type_checking_imports makes an
# annotation invisible here, and ipc.py legitimately annotates an injected SessionLoop it never
# constructs. So this forbids owning the vocabulary, not naming it — moving an import under
# TYPE_CHECKING passes the gate and is exactly the evasion the deletion has to beat.
type = forbidden
source_modules =
    saitenka.mpvio
forbidden_modules =
    saitenka.runtime

[importlinter:contract:runtime-core-independent]
name = Runtime core never imports app or mpv adapters
type = forbidden
source_modules =
    saitenka.runtime
forbidden_modules =
    saitenka.app
    saitenka.mpvio

[importlinter:contract:card-package-is-independent]
name = The card package never imports the application it was extracted from
type = forbidden
# `app/anki.py` was two things: how to reach Anki, and what to send it. Only the second is about
# cards, and it was reachable only through a module that opens sockets and launches processes.
source_modules =
    saitenka_card
forbidden_modules =
    saitenka
    ankiconnect_client
