Checker Plug-in Guide

Concept

pssparser ships with built-in syntax and semantic checks implemented in C++. The checker plug-in system lets you — or any third-party package — add custom Python checks that run as a third phase, after a successful parse and link.

Each checker is a Python class that inherits from pssparser.checkers.CheckerBase. Checkers receive a pssparser.checkers.CheckContext object that provides read access to the linked AST and an API to emit structured diagnostics.

The built-in CoreChecker (name "core") is always registered and documents every marker the C++ parser and linker can produce. It cannot be disabled.

Writing a Checker

Below is a complete, real-world example — a naming-convention checker that warns when action or struct type names do not start with an uppercase letter. The full source lives in examples/pss_naming_checker/.

# pss_naming/checker.py
from __future__ import annotations
from typing import TYPE_CHECKING

import pssparser.ast as pss_ast
from pssparser.checkers import CheckerBase, MarkerDef

if TYPE_CHECKING:
    from pssparser.checkers import CheckContext


class NamingConventionChecker(CheckerBase):
    name = "naming-convention"
    description = "Warn when action or struct type names do not start with uppercase"

    marker_defs = [
        MarkerDef(
            id="PSC001",
            severity="warning",
            summary="Action type name does not start with an uppercase letter",
            detail=(
                "PSS convention uses PascalCase for action type names.  "
                "Rename the action so that its first letter is uppercase, "
                "e.g. rename ``write_data`` to ``WriteData``."
            ),
        ),
        MarkerDef(
            id="PSC002",
            severity="warning",
            summary="Struct type name does not start with an uppercase letter",
            detail=(
                "PSS convention uses PascalCase for struct type names.  "
                "Rename the struct so that its first letter is uppercase, "
                "e.g. rename ``my_packet`` to ``MyPacket``."
            ),
        ),
    ]

    # Name-checking only needs the parse tree; no linked AST required.
    runs_without_link = True

    def check(self, context: "CheckContext") -> None:
        for global_scope in context.global_scopes:
            # context.file_map maps GlobalScope.getFileid() → source path.
            # GlobalScope.getFilename() is not reliably set by the parser.
            filename = context.file_map.get(global_scope.getFileid(), "")
            self._walk(context, global_scope, filename)

    def _walk(self, context, scope, filename: str) -> None:
        """Recursively walk *scope* and emit markers for naming violations."""
        for child in scope.children():
            if isinstance(child, pss_ast.Action):
                self._check_name(context, child, filename, "PSC001", "Action")
            elif isinstance(child, pss_ast.Struct):
                self._check_name(context, child, filename, "PSC002", "Struct")
            # Recurse into any child scope (components, packages, …)
            if isinstance(child, pss_ast.Scope):
                self._walk(context, child, filename)

    @staticmethod
    def _check_name(context, node, filename, code, kind):
        name_expr = node.getName()
        name = name_expr.getId()
        if not name or name[0].isupper():
            return
        loc = name_expr.getLocation()
        context.add_marker(
            code=code,
            file=filename,
            line=loc.lineno,
            col=loc.linepos,
            message=f"{kind} '{name}' should start with an uppercase letter",
        )

Step-by-step walkthrough:

  1. Subclass CheckerBase.

  2. Set name — a short unique slug used on the command line.

  3. Set description — shown by --list-checkers.

  4. Declare marker_defs — one MarkerDef per diagnostic code your checker can emit.

  5. Set runs_without_link = True when your checker only needs the raw parse tree (faster; works even when --syntax-only is active).

  6. Implement check(context) — walk the AST and call add_marker() to emit diagnostics.

Tip

context.file_map is a dict[int, str] mapping GlobalScope.getFileid() to the source file path. Use it instead of GlobalScope.getFilename(), which may be empty.

context.global_scopes contains only the user-supplied source files; the built-in PSS library scopes are filtered out automatically.

Declaring Markers

Every diagnostic your checker may emit must be declared as a MarkerDef in the class-level marker_defs list:

from pssparser.checkers import MarkerDef

MarkerDef(
    id="PSC001",          # globally unique ID
    severity="warning",   # "error", "warning", "info", or "hint"
    summary="Short description for --list-markers",
    detail="Longer explanation shown by --describe PSC001",
)

ID naming convention: use a three-letter prefix (e.g. PSC for your checker package) followed by a zero-padded three-digit number (PSC001). The PSS prefix is reserved for the built-in CoreChecker. Choose a unique prefix and register it in your project’s documentation to avoid future collisions.

The CheckerManager enforces globally unique IDs across all registered checkers at discovery time.

Registering via entry_points

The standard way to make your checker available to all pssparser invocations is to declare it as an entry_points contribution in your package’s setup.cfg or pyproject.toml:

setup.cfg:

[options.entry_points]
pssparser.checkers =
    naming-convention = mypkg.pss_rules:NamingConventionChecker
    unused-imports     = mypkg.pss_rules:UnusedImportChecker

pyproject.toml:

[project.entry-points."pssparser.checkers"]
naming-convention = "mypkg.pss_rules:NamingConventionChecker"
unused-imports    = "mypkg.pss_rules:UnusedImportChecker"

The left-hand side (e.g. naming-convention) becomes the registered name of the checker and is used on the command line with --checker and --no-checker.

After installation (pip install .), your checker is auto-discovered every time pssparser runs.

Ad-hoc Loading

For development or one-off use you can load a checker without installing it:

pssparser --load-checker mypkg.pss_rules:NamingConventionChecker model.pss

The --load-checker flag may be repeated to load multiple checkers. The loaded checker participates in all --checker / --no-checker filtering using its name attribute.

Combine with --list-checkers to inspect what will run before doing an actual parse:

pssparser --load-checker mypkg.pss_rules:NamingConventionChecker \
          --list-checkers

Checker Selection

By default, all registered (non-builtin) checkers run. Use these flags to change that:

--checker NAME

Run only the named checker(s). May be repeated. NAME must match a registered checker name (from entry_points) or a checker previously loaded with --load-checker. Specifying an unknown name is an error.

pssparser --checker naming-convention model.pss
--no-checker NAME

Exclude the named checker. May be repeated. Ignored when --checker is also specified (explicit selection takes precedence). Unknown names are silently ignored.

pssparser --no-checker deprecated-syntax model.pss

Precedence rules:

  1. Start with all registered + --load-checker checkers.

  2. If --checker is present, keep only those names.

  3. Otherwise, remove any names listed in --no-checker.

Querying the Registry

--list-checkers

Print a table of all registered checkers and their declared marker IDs, then exit with code 0. No source files are required.

pssparser --list-checkers
--list-markers

Print a table of every declared MarkerDef across all checkers (including the built-in core), then exit with code 0.

pssparser --list-markers
--describe ID

Print the full MarkerDef record (summary, severity, detail text, and owning checker) for a single marker ID, then exit with code 0. Exits with code 2 if the ID is not found.

pssparser --describe PSS002

Accessing the AST

Inside check(context), the linked AST is available as context.root (a RootSymbolScope) and the per-file GlobalScope nodes are in context.global_scopes. See AST Usage Guide for a full guide to navigating the AST.

To resolve a GlobalScope to its source path, use context.file_map:

filename = context.file_map.get(gs.getFileid(), "")

context.file_map is a dict[int, str] (fileid → path). GlobalScope.getFilename() is not reliably populated by the parser, so always prefer file_map.

context.global_scopes contains only the user-supplied source files; the built-in PSS library scopes are filtered out automatically.

If your checker only needs the parse tree (not the linked AST), set runs_without_link = True on the class. The checker will then run even when --syntax-only is passed. When runs_without_link = False (the default), the checker is skipped in --syntax-only mode.