Writing Rules

This guide focuses on static-analysis rules that lint already validated workflows. Tugboat validates manifests before plugins run, so hook arguments already match tugboat.schemas.

Rules sit at the core of any plugin: they run inside pluggy hooks and emit diagnoses when a manifest violates expectations. Use the guidance below once your plugin skeleton from Writing Plugins is in place.

Pick the Right Hook

Rules attach to one of the workflow-scoped hooks defined in tugboat.hookspecs:

Pick the hook that matches the scope you care about. analyze_workflow() and analyze_workflow_template() dispatch to the template and step hooks, so implementing analyze_template() or analyze_step() lets tugboat stitch the loc path while your rule concentrates on the specific violation.

Working with Schemas

Because tugboat validates manifests before invoking plugins, hook arguments are trusted Pydantic models from tugboat.schemas. Focus on linting logic—schema checks already passed.

The models you will use most often include:

  • Workflow / WorkflowTemplate, whose spec objects surface templates, arguments, and entrypoint references.

  • Template, which exposes typed collections for steps, DAG tasks, inputs, and outputs.

  • Step, which mirrors fields such as name and arguments from the owning template.

Tugboat also stamps a kind attribute on workflow objects so a rule can differentiate Workflow and WorkflowTemplate contexts whenever behaviour differs.

Reporting Diagnoses

Report each finding as a diagnosis — a plain Python dict that matches Diagnosis. Tugboat collects these payloads to render CLI output and machine-readable reports.

Every diagnosis must include:

  • code: a unique identifier for the finding (reserve a namespace for your plugin, such as MYPLUGIN).

  • loc: a tuple or list pointing to the manifest path (matching how users navigate their YAML).

  • msg: a concise, human-friendly description.

Optional keys such as type, summary, input, and fix enrich results when they add value. Yield diagnoses directly or return an iterable; tugboat will flatten the results.

Example diagnosis payloads:

diagnosis = {
    "code": "MYPLUGIN001",
    "loc": ("spec", "templates", 0, "name"),
    "msg": "Templates name 'foo' is not allowed.",
}
diagnosis = {
    "type": "failure",
    "code": "MYPLUGIN001",
    "loc": ("spec", "templates", 0, "name"),
    "summary": "Invalid template name",
    "msg": "Templates name 'foo' is not allowed.",
    "input": "foo",
    "fix": "bar",
}

Utilities such as prepend_loc() help apply shared prefixes while iterating over nested structures. Tugboat already reserves the WF / TPL / STP ranges for built-in analyzers, so keep plugin codes separate.

Shared Helpers

Tugboat includes reusable helpers in several modules to streamline rule development:

These helpers return diagnoses (or iterables), so yield from them directly to keep rules concise and wording consistent.

Example: Flag Empty Template Names

The snippet below shows a rule that verifies every template has a name:

from tugboat import hookimpl
from tugboat.schemas import Template, Workflow, WorkflowTemplate

@hookimpl(specname="analyze_template")
def check_template_name(template: Template, workflow: Workflow | WorkflowTemplate):
    if not template.name:
        yield {
            "code": "MYPLUGIN001",
            "loc": ["name"],
            "msg": "Templates must define a unique name.",
        }

The workflow argument gives context about the parent manifest. If you do not need it, like this example, you can omit it from the function signature.