Skip to main content

Architecture

This page is the conceptual map of the framework. Read it before writing a custom rule — most rule bugs trace back to a misunderstanding of one of these components.

Pipeline

A lint run flows through four phases:

view.json → flatten → build model → run rules → report
PhaseModuleOutput
Flattencommon/flatten_json.pyPath → value pairs
Build modelmodel/builder.pyObject tree of typed nodes
Run ruleslinter.py + rules/*Per-rule violations
Reportcli.pyGrouped, severity-aware output

Phase 1 — Flattening

The flattener converts a hierarchical Perspective view into a flat dictionary keyed by dot-paths. Array indices are bracketed:

{
"root": {
"children": [{
"meta": {"name": "Button"},
"props": {"text": "Click Me"}
}]
}
}

becomes

{
"root.children[0].meta.name": "Button",
"root.children[0].props.text": "Click Me"
}

This representation is what every downstream phase reads. Some rules (ExcessiveContextDataRule, UnusedCustomPropertiesRule) skip the model and operate directly on flattened JSON for performance or coverage reasons.

Phase 2 — Model building

ViewModelBuilder walks the flattened JSON and produces typed nodes. Every node has a .path (its location in the original JSON) and a .node_type (the enum below).

Node types

src/ignition_lint/model/node_types.py
Enum valueClassDescription
COMPONENTComponentUI components — buttons, labels, containers
PROPERTYPropertyComponent or view-level properties
EXPRESSION_BINDINGExpressionBindingexpr bindings
EXPRESSION_STRUCT_BINDINGExpressionStructBindingMulti-expression struct bindings
PROPERTY_BINDINGPropertyBindingproperty-to-property bindings
TAG_BINDINGTagBindingtag bindings (direct, expression, indirect modes)
QUERY_BINDINGQueryBindingnamed-query bindings
MESSAGE_HANDLERMessageHandlerScriptmessage handlers
CUSTOM_METHODCustomMethodScriptcomponent custom methods
TRANSFORMTransformScriptscript transforms inside bindings
EVENT_HANDLEREventHandlerScriptevent handler scripts

Convenience sets:

  • ALL_BINDINGS — every binding type
  • ALL_SCRIPTS — every script type

Each node class adds type-specific attributes: Component.name, ExpressionBinding.expression, TagBinding.tag_path / mode / references, ScriptNode.script and get_formatted_script(), etc.

Phase 3 — Rule execution (visitor pattern)

The framework uses the visitor pattern to dispatch each node to the right rule method without coupling node classes to rule classes.

src/ignition_lint/rules/common.py

How it works

  1. Rule declares interest. Each rule's __init__ calls super().__init__(target_node_types) with the node types it cares about.
  2. LintEngine filters nodes. Before calling visit methods, the engine filters the model to only nodes matching target_node_types.
  3. Double dispatch. For each filtered node, the engine calls node.accept(rule). The node knows its own type and routes to the right visit_* method on the rule.
# What you write in a rule
def visit_component(self, node: ViewNode):
if some_condition:
self.add_violation(f"{node.path}: explanation")

# What the framework does
for node in model.filter(rule.target_node_types):
node.accept(rule) # → rule.visit_component(node)

Visit methods

Every rule inherits a complete set of empty visit_* methods from NodeVisitor. Override only what you need:

class NodeVisitor:
def visit_component(self, node): pass
def visit_property(self, node): pass
def visit_expression_binding(self, node): pass
def visit_property_binding(self, node): pass
def visit_tag_binding(self, node): pass
def visit_message_handler(self, node): pass
def visit_custom_method(self, node): pass
def visit_transform(self, node): pass
def visit_event_handler(self, node): pass
def visit_generic(self, node): pass # fallback

Lifecycle hooks

Rules can override these in addition to visit methods:

HookWhen calledUse it for
before_visit()Before any nodes are visitedReset state, prepare caches
visit_*()Once per matching nodePer-node logic
post_process()After all nodes visitedCross-node analysis, batch processing
finalize()After post_processCleanup, summary output

Severity and violation reporting

add_violation(message, severity=None) is the canonical way to report. It appends to self.errors or self.warnings based on severity:

self.add_violation(f"{node.path}: violation message")
self.add_violation(f"{node.path}: also a violation", severity="warning")

Don't append directly to self.errors in new rules — add_violation handles severity routing centrally.

Specialized base classes

ClassTargetsAdds
LintingRuleAnything (target_node_types set in __init__)Base visitor, severity routing
BindingRuleALL_BINDINGS by defaultConvenient default for binding-only rules
ScriptRuleALL_SCRIPTS by defaultAuto-collects scripts into self.collected_scripts, calls process_scripts() for batch analysis
FixableMixinMix in alongside any rule baseAdds add_fix() / get_fixes(); framework integrates fixes when --fix is requested

Use the most specific base class. A rule that only ever looks at scripts should subclass ScriptRule, not LintingRule.

Lint engine

LintEngine (linter.py) orchestrates the whole pipeline:

  1. Loads the configured rules from rule_config.json
  2. Flattens the view
  3. Builds the model
  4. For each rule, filters nodes by target types and calls visit methods
  5. Calls post_process() and finalize() hooks
  6. Collects violations and returns a structured report

For batch runs (multiple files), the engine processes one file at a time but allows rules with batch_mode=True (currently only PylintScriptRule) to accumulate state across files and report once at the end.

Auto-fix

Rules that inherit FixableMixin can produce Fix objects describing JSON edits. When --fix is enabled (or via the framework API), the engine applies safe fixes automatically and reports unsafe ones for human review.

A Fix includes:

FieldPurpose
rule_nameWhich rule produced it
violation_messageThe violation it addresses
descriptionHuman-readable explanation
operationsList of FixOperation (JSON path edits)
is_safeWhether automated application is safe
safety_notesIf unsafe, why

See src/ignition_lint/common/fix_operations.py for the full schema and name_pattern.py / lint_script.py for working examples.

Where rules live

src/ignition_lint/rules/
├── common.py # base classes (LintingRule, BindingRule, ScriptRule, FixableMixin)
├── registry.py # auto-discovery
├── naming/ # NamePatternRule
├── structure/ # BadComponentReferenceRule, ComponentReferenceValidationRule
├── performance/ # PollingIntervalRule
├── properties/ # UnusedCustomPropertiesRule, ExcessiveContextDataRule
├── scripts/ # PylintScriptRule
└── _examples/ # reference rules — excluded from auto-discovery

Any .py file under rules/ (excluding _examples/, __init__.py, registry.py, common.py) is auto-discovered. Subdirectories are recursed.

See also