Skip to content

spanforge.lint — SDK Instrumentation Linter

Module: spanforge.lint
Added in: 1.0.7

spanforge.lint is a static analysis tool that inspects Python source files for common spanforge instrumentation mistakes before the code runs. It ships as:

  1. A Python API — call run_checks() from test suites or CI scripts.
  2. A flake8 / ruff plugin — AO-codes appear inline in your editor and linting output with no extra configuration.
  3. A CLIpython -m spanforge.lint myapp/ for one-shot sweeps.

Quick example

from spanforge.lint import run_checks

errors = run_checks(
    source=open("myapp/pipeline.py").read(),
    filename="myapp/pipeline.py",
)

for err in errors:
    print(f"{err.filename}:{err.line}:{err.col}: {err.code} {err.message}")

Output example:

myapp/pipeline.py:17:1:  AO001 Event() is missing required field 'payload'
myapp/pipeline.py:42:12: AO002 actor_id receives a bare str literal; wrap with Redactable()
myapp/pipeline.py:53:5:  AO004 LLM provider call outside tracer span context

run_checks()

def run_checks(source: str, filename: str = "<string>") -> list[LintError]

Parse source as valid Python 3 with ast, visit every node, and return a list of LintError objects (empty list when the file is clean).

ParameterTypeDefaultDescription
sourcestrUTF-8 Python source code to analyse
filenamestr"<string>"File path; used in LintError.filename

Returns: list[LintError] sorted by (line, col).

Raises: SyntaxError (surfaced as LintError with code AO000) when source contains a syntax error.


LintError

@dataclass(frozen=True)
class LintError:
    code: str        # e.g. "AO001"
    message: str     # human-readable description
    filename: str    # file the error was found in
    line: int        # 1-based line number
    col: int         # 1-based column number

All fields are immutable. LintError objects are hashable and safe to store in sets.


Error codes

AO000 — Syntax error

AO000  Syntax error: {detail}

The source file could not be parsed. Returned instead of raising SyntaxError so callers can handle all lint results uniformly. All other checks are skipped when AO000 is returned.


AO001 — Missing required Event() field

AO001  Event() is missing required field '{field}'

Triggered when an Event(...) constructor call is missing one of the three required keyword arguments: event_type, source, or payload.

Bad:

event = Event(event_type="llm.trace.span.completed", source="my-app@1.0.0")
# AO001: 'payload' is not provided

Good:

event = Event(
    event_type="llm.trace.span.completed",
    source="my-app@1.0.0",
    payload=span.to_dict(),
)

AO002 — Bare str literal for identity field

AO002  '{field}' receives a bare str literal; wrap with Redactable()

Triggered when actor_id, session_id, or user_id is assigned a bare string literal. These fields often contain PII and should be wrapped in Redactable() so the redaction pipeline can process them.

Bad:

event = Event(..., actor_id="user-12345")

Good:

from spanforge import Redactable
event = Event(..., actor_id=Redactable("user-12345", sensitivity="HIGH"))

AO003 — Unknown event_type string

AO003  event_type string '{value}' is not a registered EventType value

Triggered when event_type= is assigned a string literal that is not present in spanforge.types.EventType. This catches typos like "llm.trace.spam.completed" before they produce silently-invalid events.

Bad:

event = Event(event_type="llm.trase.span.completed", ...)  # typo

Good:

from spanforge.types import EventType
event = Event(event_type=EventType.SPAN_COMPLETED, ...)
# or use the validated string:
event = Event(event_type="llm.trace.span.completed", ...)

AO004 — LLM call outside trace context

AO004  LLM provider call outside tracer span context

Triggered when a call matching the pattern *.chat.completions.create(), *.messages.create(), *.generate(), etc. is detected outside a with tracer.span(...) or async with agent_run(...) block. Without a span context the call will produce no audit telemetry.

Bad:

response = client.chat.completions.create(model="gpt-4o", messages=[...])

Good:

async with tracer.span("call-llm"):
    response = client.chat.completions.create(model="gpt-4o", messages=[...])

AO005 — Emit call outside agent context

AO005  emit_span / emit_agent_* called outside agent_run() / agent_step() context

Triggered when emit_span(), emit_agent_run(), or emit_agent_step() is called outside an agent_run() or agent_step() context manager. Emitting these events without a surrounding agent context means there is no parent trace to attach them to.

Bad:

emit_span(my_span)  # top-level, no agent context

Good:

async with agent_run("my-agent") as run:
    emit_span(my_span)

flake8 / ruff plugin

All five AO-codes are surfaced natively when flake8 or ruff processes files that import spanforge. The plugin is registered via the [project.entry-points."flake8.extension"] entry in pyproject.toml:

[project.entry-points."flake8.extension"]
AO = "spanforge.lint._flake8:SpanForgeChecker"

After installing spanforge in your project, run:

flake8 myapp/
# or
ruff check myapp/

AO-codes appear alongside PEP-8 and other style warnings with no extra configuration.

To disable a specific code on a line, use a # noqa: AO002 comment:

actor_id = "system"  # noqa: AO002

CLI

python -m spanforge.lint [FILES_OR_DIRS...]

Recursively scans every *.py file in the paths you specify. If no paths are given, the current directory is scanned.

Exit codes:

CodeMeaning
0No errors found
1One or more AO-errors found
2Internal error (e.g. a path does not exist)

Example:

# Check the whole project
python -m spanforge.lint .

# Check one file
python -m spanforge.lint myapp/pipeline.py

# Output
myapp/pipeline.py:17:1  AO001 Event() is missing required field 'payload'
myapp/pipeline.py:53:5  AO004 LLM provider call outside tracer span context
2 errors in 1 file.

Add to your CI pipeline (Makefile, GitHub Actions, etc.):

- name: spanforge lint
  run: python -m spanforge.lint myapp/

SpanForgeChecker (flake8 internals)

class SpanForgeChecker:
    name: str = "spanforge-lint"
    version: str = "1.0.8"
    def __init__(self, tree: ast.AST, filename: str = "(none)") -> None: ...
    def run(self) -> Iterable[tuple[int, int, str, type]]: ...

This class is used internally by the flake8 plugin system. Application code should not need to instantiate it directly — use run_checks() instead.


See also