Bardic 0.8.0: Linter
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 CIThe 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 @propertydecorators- Private-to-public field mappings (
_trust→trust) - Inheritance chains
It maps story variables to classes via instantiation patterns (blackthorn = BlackthornManor(...)) and fuzzy matching (session → Session).
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-pluginsflag 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, everythingparse_attribute_access(code): AST-based extraction of attribute writes, reads, and method callsLintReport,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 bardicNo 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.