Static Analysis

Analyze templates for dependencies, purity, and caching potential

15 min read 3085 words

Kida can statically analyze compiled templates to extract dependency information, determine output purity, and recommend caching strategies — all without rendering. No other Python template engine provides this capability.

What Analysis Provides

Every compiled template exposes these analysis results:

Capability Method Returns
Context dependencies template.required_context() Top-level variable names the template accesses
Full dependency paths template.depends_on() Dotted paths likepage.title, site.pages
Block metadata template.block_metadata() Per-block purity, dependencies, cache scope
Full metadata template.template_metadata() Complete analysis including inheritance info
Cache check template.is_cacheable("nav") Whether a block can be safely cached
Context validation template.validate_context(ctx) Missing variable names before rendering

Quick Start

from kida import Environment, DictLoader

env = Environment(loader=DictLoader({
    "page.html": """
        {% extends "base.html" %}
        {% block title %}{{ page.title }}{% end %}
        {% block nav %}
            <nav>{% for item in site.menu %}<a href="{{ item.url }}">{{ item.label }}</a>{% end %}</nav>
        {% end %}
        {% block content %}{{ page.content }}{% end %}
    """,
    "base.html": """
        <html>
        <head><title>{% block title %}{% end %}</title></head>
        <body>{% block nav %}{% end %}{% block content %}{% end %}</body>
        </html>
    """,
}))

template = env.get_template("page.html")

Check What Variables a Template Needs

>>> template.required_context()
frozenset({'page', 'site'})

Validate Context Before Rendering

>>> template.validate_context({"page": page_obj})
['site']  # 'site' is missing

>>> template.validate_context({"page": page_obj, "site": site_obj})
[]  # all required variables present

Inspect Block-Level Metadata

>>> meta = template.block_metadata()
>>> nav = meta["nav"]
>>> nav.depends_on
frozenset({'site.menu'})
>>> nav.is_pure
'pure'
>>> nav.cache_scope
'site'
>>> nav.is_cacheable()
True

Determine Caching Strategy

>>> template.is_cacheable("nav")    # site-wide cache
True
>>> template.is_cacheable("content")  # page-specific, still cacheable
True
>>> template.is_cacheable()          # all blocks cacheable?
True

Analysis Concepts

Shape Profiles

Use the opt-in shape profiler when tooling needs deterministic structural facts without asking Kida to decide whether a component should be extracted or flattened:

from kida.analysis import profile_source, profile_template

source_report = profile_source(source, name="components/card.kida")
compiled_report = profile_template(template)

for profile in source_report.profiles:
    print(profile.kind, profile.name, profile.span, profile.facts.node_count)

payload = source_report.to_dict()  # JSON-compatible kida.shape-profiles v1

Reports contain one whole-template profile followed by source-ordered profiles for{% def %}, {% region %}, {% block %}, and {% fragment %}owners. Each immutableShapeFactsrecord keeps node and source volume, depth, branches, loops, dynamic expressions and density, component calls, slots, literal HTML attributes, repeated AST shapes, conservative context dependencies, and a normalized structural fingerprint as separate facts. There is deliberately no combined score, severity, suggestion, or automatic edit.

Every profile has an exactSourceSpan. profile_template()re-analyzes the compiled template's retained source, so a fresh compilation and a bytecode-cache hit describe the same source shape instead of optimization artifacts. This is an opt-in analysis cost and does not change rendering or default compilation.

The shape-profile surface is programmatic only. It does not add a CLI mode, directory/multi-root discovery, or a rootkidaexport. Callers can serialize report.to_dict() with the standard jsonmodule when they need stable machine-readable facts.

Extraction Candidate Advice

Tooling can opt into conservative component-boundary advice for one source or compiled template:

from kida.analysis import advise_extraction_source, advise_extraction_template

source_report = advise_extraction_source(source, name="messages.kida")
compiled_report = advise_extraction_template(template)

for diagnostic in source_report.diagnostics:
    print(diagnostic.code, diagnostic.span, dict(diagnostic.metadata))

K-MOD-102 is an informational, conservative Diagnostic. Kida emits it only when several independent signals converge: a substantial iterated region or normalized repeated siblings, owned interaction/accessibility structure, and a small lexical input boundary. A loop, repeated markup, template size, or branch count alone is not enough. Existing defs, regions, blocks, and call boundaries are not re-recommended.

Each finding has an exact primary span, contributing signals, tentative props, possible slots, context and component dependencies, provide/consume dependencies, and related locations for repeated siblings. Metadata list values are deterministic compact JSON strings. The result deliberately has no combined score and nosafe_edit: component ownership and reuse remain human decisions.

This API is single-source and opt-in. It does not changeDiagnosticOptions, diagnose_source(), kida check, default compilation, suppression syntax, or rendering. Directory and multi-root orchestration belong to a later adapter contract.

Multi-Root Encapsulation Advice

Explicitly owned roots can be analyzed as one deterministic component call graph:

from pathlib import Path

from kida.inspection import TemplateRoot, advise_encapsulation_roots

report = advise_encapsulation_roots(
    (
        TemplateRoot("app", Path("templates")),
        TemplateRoot("adapter", Path("adapter-templates")),
    )
)

The report preserves K-MOD-102 extraction candidates and adds K-MOD-103 for exact pass-through components. Flatten advice requires one same-owner caller, one downstream component, an identical typed/defaulted prop interface, at most one identically forwarded slot, and no owned markup or behavior. Reuse, zero local callers, cross-root callers or downstream components, markup, control flow, accessibility structure, context policy, changed defaults, renamed/narrowed props, or a different slot surface suppress the finding.

Every flatten finding includes an exact definition span, the only caller and downstream definition as related locations, contributing signals, owner/source facts, and forwarded props/slots. It is informational and conservative, has no safe edit, and explicitly asks the reviewer to preserve documented public, product, test, and adapter boundaries.

This programmatic API is opt-in. It does not add a CLI flag or change default kida checkbehavior. Root ownership is explicit; Kida does not infer package or adapter semantics from ambient imports.

Adapter Advice Context

Framework adapters can translate their own route, response, or component facts into immutable context attached to exact profile spans:

from kida.analysis import AdviceContext, profile_source
from kida.inspection import TemplateRoot, advise_encapsulation_roots

profile = profile_source(source, name="app/messages.kida")
response_block = next(item for item in profile.profiles if item.kind == "block")
context = AdviceContext(
    response_block.span,
    (
        ("consumer_context", "iterated"),
        ("preserve_boundary", True),
        ("response_boundary", True),
        ("role", "framework-response"),
    ),
)

report = advise_encapsulation_roots(roots, context=(context,))

The recognized generic facts are consumer_context ("iterated"or "repeated"), boolean preserve_boundary and response_boundary, descriptive role, and visibility ("package" or "public"). An iterated response block stays intact while nested extraction candidates can become visible. Visibility and preservation facts suppress flatten advice only for an exact definition span.

Unknown facts and values are ignored; a context containing only unknown facts produces the same report as no adapter context. Kida does not interpret framework modifier names or import framework packages. Facts are sorted, invocation-local, and represented in ordinary diagnostic metadata, so no new schema or shared registry is introduced. See the adapter advice-context contract for the complete matching and compatibility rules.

Evidence-Driven Encapsulation Loop

Humans and coding agents can use the same supported loop without a specialized Kida orchestration layer:

  1. Runadvise_encapsulation_roots()and consume each finding's code, exact span, confidence, metadata, and related locations.
  2. Choose extraction, pass-through inlining, or intentional preservation from that evidence; Kida does not make the ownership decision.
  3. Rundiagnose_roots(..., options=DiagnosticOptions(validate_calls=True))to validate the resulting props and slots.
  4. Render the same context before and after, including named response blocks.
  5. Re-run advice and record remaining findings, false positives, false negatives, context effects, and analysis cost.

The runnable examples/encapsulation_loop commits five before/after cases and deterministic calibration JSON: a growing route, a healthy large layout, a pass-through micro-component, an adapter-preserved response boundary, and multiple explicit roots. The replay proves render and call-validation parity while leaving ambiguous edits to the consumer. Optional--measureoutput stays outside the stable snapshot.

uv run python examples/encapsulation_loop/app.py
uv run python examples/encapsulation_loop/app.py --measure --rounds 5

Dependencies

The dependency walker extracts every context variable path a template accesses. Results are conservative: they may include paths not taken at runtime (e.g., both branches of an{% if %}) but never miss a path that is used.

>>> template.depends_on()
frozenset({'page.title', 'page.content', 'site.menu'})

required_context()extracts just the top-level names:

>>> template.required_context()
frozenset({'page', 'site'})

Purity

A block is pure if its output is deterministic given the same inputs. Pure blocks can be cached safely. Impure blocks (usingrandom, shuffle, or similar) must be re-rendered each time.

Purity Meaning Cacheable?
"pure" Deterministic output Yes
"impure" Uses non-deterministic functions No
"unknown" Cannot determine statically Treat as impure

Cache Scope

Cache scope tells you how broadly a block's output can be reused:

Scope Meaning Example
"site" Same output for every page Navigation, footer
"page" Varies per page but stable for a given page Content, title
"none" Cannot cache (impure block) Random quotes
"unknown" Cannot determine Mixed dependencies

Landmarks

Kida detects HTML5 landmark elements (<nav>, <main>, <header>, <footer>, <aside>) in block output and uses them to infer the block's role:

>>> meta["nav"].emits_landmarks
frozenset({'nav'})
>>> meta["nav"].inferred_role
'navigation'

Context Validation

validate_context()is designed for build systems, SSR frameworks, and testing pipelines that need to catch missing variables before rendering:

template = env.get_template("email.html")
missing = template.validate_context(user_context)
if missing:
    raise ValueError(f"Missing template variables: {missing}")
result = template.render(**user_context)

This runs dependency analysis (cached after first call) and compares required top-level variable names against the provided context keys plus environment globals. It returns a sorted list of missing names, or an empty list if everything is present.

Dotted Context Contracts

Frameworks that know more than top-level keys can compare a route or handler contract against Kida's dotted dependency paths:

from kida.analysis import check_context_contract

issues = check_context_contract(
    template,
    provided={"page.title", "page.author.name"},
    globals={"csrf_token"},
    optional={"flash.message"},
)

for issue in issues:
    print(issue.code, issue.path, issue.message)

provided, globals, and optionalcan be dotted-path iterables or nested mappings. This checker is deliberately route-agnostic: frameworks supply the contract shape, and Kida reports only whether template dependency paths are covered. Enablecheck_extra=Truewhen a narrow contract should also warn about provided paths the template does not read.

K-CTX-001 and K-CTX-002 are registered in the public ErrorCodeenum; ContextContractIssue.coderemains the same string-valued field for backward compatibility.

Literal Attribute Extraction

Framework adapters and CI reports can inspect literal HTML attributes without parsing Kida's AST themselves:

from kida.analysis import extract_literal_attributes

attrs = extract_literal_attributes(
    template,
    prefixes=("data-", "hx-"),
)

for attr in attrs:
    print(attr.tag, attr.name, attr.value, attr.lineno)

This reports only attributes visible in static template text. Dynamic attributes such as{{ attrs }}, xmlattr, or helper-generated markup are intentionally not inferred. Kida provides the generic facts; frameworks decide whether a literaldata-*, hx-*, id, or other attribute has route-specific meaning.

Escape Audit

Useaudit_escaping()to inventory where a template outputs escaped values, where autoescape is disabled, and where filters intentionally produce trusted markup:

from kida.analysis import audit_escaping

for finding in audit_escaping(template):
    print(finding.code, finding.kind, finding.expression, finding.message)

Findings are static diagnostics only. They do not change rendering and do not prove user input was sanitized.| safefindings include the optional reason=text when present; missing reasons get a suggestion so code review and CI reports can point at explicit trust boundaries. CodesK-ESC-001through K-ESC-005 are registered in the public ErrorCodeenum without changing the existing string-valuedEscapeAuditFinding.codefield.

Privacy Lint

Uselint_privacy()to catch likely private data exposure before templates or report fixtures are published:

from kida.analysis import lint_privacy

for finding in lint_privacy(template):
    print(finding.code, finding.kind, finding.path, finding.message)

The first version is intentionally narrow. It reports sensitive-looking context paths, secret-like string literals without echoing their values,| safeon sensitive-looking values, broad debug context output, and dynamic template names that a framework policy cannot statically allowlist. CodesK-PRI-001through K-PRI-005 are registered in the public ErrorCodeenum; findings retain their existing string-valuedcodefield.

Call-Site Validation

Kida can validate{% def %}call sites at compile time, catching parameter errors before any template is rendered:

from kida import Environment

env = Environment(validate_calls=True)

template = env.from_string("""
    {% def button(text: str, url: str, style="primary") %}
        <a href="{{ url }}" class="btn btn-{{ style }}">{{ text }}</a>
    {% end %}

    {{ button(text="Save", urll="/save") }}
""")
# UserWarning: Call to 'button' at <string>:6 — unknown params: urll; missing required: url

What It Checks

Issue Example
Unknown params Callingbutton(labl="X") when param is label
Missing required Callingbutton() when texthas no default
*args / **kwargsrelaxation Definitions with*args or **kwargssuppress unknown-param warnings

Programmatic API

For build systems and CI pipelines, use theBlockAnalyzerdirectly:

from kida import Environment, DictLoader
from kida.analysis import BlockAnalyzer

env = Environment(
    loader=DictLoader({"page.html": src}),
    preserve_ast=True,
)
template = env.get_template("page.html")

analyzer = BlockAnalyzer()
issues = analyzer.validate_calls(template._optimized_ast)

for issue in issues:
    if not issue.is_valid:
        print(f"{issue.def_name} at line {issue.lineno}: "
              f"unknown={issue.unknown_params}, "
              f"missing={issue.missing_required}")

CallValidation

Field Type Description
def_name str Name of the called{% def %}
lineno int Line number of the call site
col_offset int Column offset of the call site
unknown_params tuple[str, ...] Keyword args not in the definition
missing_required tuple[str, ...] Required params not provided
duplicate_params tuple[str, ...] Params passed more than once; parser-produced ASTs reject these before validation
Property Description
is_valid Trueif no issues were found

Configuration

UseAnalysisConfigto customize analysis behavior for your framework:

from kida import AnalysisConfig
from kida.analysis import BlockAnalyzer

config = AnalysisConfig(
    # Variables indicating page-specific scope
    page_prefixes=frozenset({"post.", "post", "article.", "article"}),
    # Variables indicating site-wide scope
    site_prefixes=frozenset({"settings.", "settings", "global."}),
    # Additional functions your framework guarantees are pure
    extra_pure_functions=frozenset({"asset_url", "t", "current_lang"}),
    # Filters that produce non-deterministic output
    extra_impure_filters=frozenset({"random_choice"}),
)

analyzer = BlockAnalyzer(config=config)

Kida ships with a default config (DEFAULT_CONFIG) that includes common SSG pure functions likeasset_url, t, canonical_url, etc.

Historical Case Study: Bengal Static Site Generator

Bengal uses Kida's analysis API to implement smart incremental builds:

  1. 1

    Compile all templates

    Enable AST preservation when compiling.

  2. 2

    Analyze each template

    Get block metadata for each compiled template.

  3. 3

    Identify site-cacheable blocks

    Find nav, footer, sidebar usingcache_scope == "site".

  4. 4

    Cache site-scoped blocks

    Cache once per build, reuse across all pages.

  5. 5

    Re-render page-scoped blocks only

    Only when page content changes.

  6. 6

    Track dependencies

    Invalidate caches when upstream data changes.

This reduces full-site rebuild time by 40-60% for sites with shared navigation and footer blocks.

Case Study: Chirp Web Framework

Chirp uses Kida's introspection and block APIs for dynamic web apps:

  1. 1

    Composition planning

    Usetemplate_metadata()to discover blocks and inheritance before rendering.

  2. 2

    Block validation

    Callvalidate_block_exists(env, template, block) before render_block()to avoid KeyError.

  3. 3

    Fragment rendering

    Userender_block()for HTMX partial responses and Turbo Stream updates.

  4. 4

    Layout assembly

    Userender_with_blocks()to inject pre-rendered content into layout templates.

  5. 5

    Adapter pattern

    KidaAdapter implements Chirp's TemplateAdapterinterface, wrapping all Kida APIs.

See Framework Integration for the full adapter pattern and API usage.

API Reference

Template Methods

Method Signature Description
required_context() () -> frozenset[str] Top-level variable names needed
depends_on() () -> frozenset[str] All dotted dependency paths
validate_context() (context: dict) -> list[str] Missing variable names
check_context_contract() (template, provided, ...) -> list[ContextContractIssue] Dotted context contract diagnostics
extract_literal_attributes() (template_or_ast, names=..., prefixes=...) -> list[LiteralAttribute] Literal HTML attributes with source locations
audit_escaping() (template_or_ast, include_output_sites=True) -> list[EscapeAuditFinding] Static escape and trusted-markup findings
lint_privacy() (template_or_ast) -> list[PrivacyFinding] Sensitive path, secret literal, and broad-output findings
block_metadata() () -> dict[str, BlockMetadata] Per-block analysis results
template_metadata() () -> TemplateMetadata | None Full template analysis
is_cacheable() (block_name: str | None) -> bool Cache safety check
list_blocks() () -> list[str] Block names in template

BlockMetadata

Field Type Description
name str Block identifier
modifiers tuple[BlockModifierMetadata, ...] Ordered literal framework metadata with source locations
depends_on frozenset[str] Context paths accessed
is_pure "pure" | "impure" | "unknown" Determinism classification
cache_scope "site" | "page" | "none" | "unknown" Recommended cache level
emits_html bool Whether block produces output
emits_landmarks frozenset[str] HTML5 landmarks detected
inferred_role str Heuristic role classification
is_region bool True if block is a{% region %}
region_params tuple[str, ...] Parameter names (regions only)

BlockMetadata.get_modifier(name)returns the matching immutable BlockModifierMetadata or None. Each modifier exposes name, its typed scalarvalue, and the one-based lineno / zero-based col_offsetwhere its name begins.

TemplateMetadata

Field Type Description
name str | None Template identifier
extends str | None Parent template name
blocks dict[str, BlockMetadata] All block metadata
top_level_depends_on frozenset[str] Dependencies outside blocks
Method Description
all_dependencies() Union of all block and top-level dependencies
get_block(name) Get metadata for a specific block
regions() Return only region-typed blocks (for OOB discovery)
cacheable_blocks() List of blocks whereis_cacheable()is True
site_cacheable_blocks() List of blocks withcache_scope == "site"

CallValidation

Field Type Description
def_name str Name of the called{% def %}
lineno int Line number of the call site
col_offset int Column offset of the call site
unknown_params tuple[str, ...] Keyword args not in the definition
missing_required tuple[str, ...] Required params not provided
duplicate_params tuple[str, ...] Params passed more than once
Property Type Description
is_valid bool Trueif no issues were found

AnalysisConfig

Field Type Default Description
page_prefixes frozenset[str] {"page.", "page", ...} Page-scope variable prefixes
site_prefixes frozenset[str] {"site.", "site", ...} Site-scope variable prefixes
extra_pure_functions frozenset[str] frozenset() Additional pure function names
extra_impure_filters frozenset[str] frozenset() Additional impure filter names

Integration with Frameworks

Frameworks like Chirp use Kida templates and run contract validation (chirp check) to verify that hx-post, hx-get, and action URLs in templates match registered routes. Dynamic URLs built with ~ or {{ var }}are correctly skipped — only literal URLs are validated.