Skip to content

Bardic 0.8.0: Linter

Choose a tag to compare

@katelouie katelouie released this 12 Mar 02:45
· 32 commits to main since this release

We have a linter now. It's graph-aware, compile-first, class-resolving, and extensible. It understands story structure and follows @include chains, as long as you point it at your main .bard file. It reads your Python source code and understands your class hierarchies. Hopefully it will make the development and debugging process a lot smoother and catch a lot of runtime bugs before shipping.


New Features

bardic lint: Structural Story Analysis

bardic lint story.bard
bardic lint story.bard --verbose
bardic lint story.bard --errors-only
bardic lint story.bard --json-output   # for CI

The linter compiles your story first (following all @include directives) then analyzes the full passage graph. This means it catches things a regex scan never could.

Errors (will crash at runtime):

Code What it catches
E001 Broken jump targets — passage references that don't exist
E002 Duplicate passage names

Warnings (probably wrong, definitely worth knowing):

Code What it catches
W001 Orphaned passages — defined but never jumped to
W002 Dead-end passages — no choices, jumps, or hooks
W003 Empty passages — no content, no choices, no code
W004 Sticky self-loops — + choices that jump to their own passage
W005 Attribute reads without writes, with typo suggestions

W005 deserves a moment. When you write {session.artifacts_recieved} in your story, Bardic now says:

W005: Attribute 'artifacts_recieved' read but never written
      Did you mean 'artifacts_received'?

That's difflib doing the heavy lifting. Typos in attribute names are the silent killer of late-session story branches, and now they make noise.

Informational (shown with --verbose):

Code What it shows
I001 Dead-ends with ending-like names (probably intentional)
I002 Passages with many visible choices

I002 uses MAX across @if branches, not SUM — because players only ever see one branch. The linter knows this.

Human-readable output with colored severity icons by default. Structured JSON (--json-output) for CI pipelines.


Level 2: Class-Aware Attribute Checking

The attribute checker doesn't stop at variables explicitly assigned in .bard code. It follows your story's from game_logic.X import Y statements, locates the Python source files on disk, and parses class definitions with AST.

It resolves:

  • Class fields and __init__ assignments
  • @property decorators
  • Private-to-public field mappings (_trusttrust)
  • Inheritance chains

It maps story variables to classes via instantiation patterns (blackthorn = BlackthornManor(...)) and fuzzy matching (sessionSession).

In practice: if Client defines self.artifacts_received in Python, and your story reads {client.artifacts_received}, that's not a W005. The linter knows it's defined. No false positives.


Lint Plugin System

Drop a .py file in your project's linter/ directory. Any function named check_* is auto-discovered and run after built-in checks.

# linter/check_arcanum_conventions.py

def check_no_direct_trust_writes(story_data, report, project_root):
    """Trust should only be modified through client.adjust_trust(), not direct assignment."""
    from bardic.linting import extract_python_code, parse_attribute_access
    
    for block in extract_python_code(story_data):
        accesses = parse_attribute_access(block)
        for write in accesses["writes"]:
            if write.endswith(".trust") and "adjust_trust" not in block:
                report.warning(
                    "P000",
                    f"Direct trust write '{write}' — use adjust_trust() instead",
                )

Plugin signature: def check_something(story_data, report, project_root)

Rules:

  • Files starting with _ are ignored (use for shared helpers)
  • Plugin failures are caught gracefully, reported as P000 warnings
  • --no-plugins flag to skip all project plugins
  • Plugin count shown in output header

Public helper API for plugin authors:

  • extract_python_code(story_data): all Python from the compiled story: imports, @py: blocks, expressions, conditions, everything
  • parse_attribute_access(code): AST-based extraction of attribute writes, reads, and method calls
  • LintReport, Severity, Diagnostic: fully importable for building custom diagnostics

Full docs: docs/lint-plugins.md


linter/ in Project Templates

bardic init now scaffolds a linter/ directory with an example plugin in all templates (nicegui, web, reflex). The example demonstrates the API and suggests real use cases. The bardic init output also now tells you bardic lint exists, because you should be running it.


Bug Fixes

Indented @py: Blocks Were Silently Skipped

Python code inside @py: blocks within @if branches retains its indentation in compiled output. ast.parse rejects leading whitespace, so these blocks were silently skipped during attribute analysis, meaning any attribute written inside a conditional Python block was invisible to the linter.

Fixed with textwrap.dedent() before parsing. All writes at all nesting depths are now caught.

This one mattered: most interesting state mutations in Arcanum-style stories happen inside conditional blocks.

Top-Level Imports Not Extracted

Compiled story JSON stores from X import Y statements in a separate imports array, not inside passage execute blocks. The Python code extractor wasn't including these, which broke class-aware checking for any type that was only referenced via import.

Fixed: imports are now included in extraction. Class resolution works correctly for all imported types.


Upgrade Notes

pip install --upgrade bardic

No story file changes needed. The linter is additive -- run it on your existing .bard files and see what it finds.

If you want project-specific checks, create a linter/ directory at your project root and start dropping in check_*.py files. bardic init will scaffold this automatically for new projects.