Skip to content
Built by Postindustria. We help teams build agentic production systems.

Static Linting (lint)

lint() walks every node in a Construct and verifies that each FromInput/FromConfig parameter has a matching key in the provided config dict and that prompt placeholders resolve to known input keys. It never raises — it returns a list of LintIssue instances describing every binding problem it finds.

from neograph import lint, LintIssue
issues = lint(
pipeline,
config={"node_id": "test", "project_root": "/tmp"},
known_template_vars={"topic", "json_schema"},
template_resolver=my_resolver,
)
for issue in issues:
print(f"[{issue.kind}] {issue.message}")
ParameterTypeDescription
constructConstructThe pipeline to check
configdict[str, Any] | NoneConfig dict to validate DI bindings against. When None, only structural checks run (required params are flagged as missing since no config is available).
known_template_varsset[str] | NoneSet of extra variable names the consumer’s prompt pipeline provides. Merged with framework extras (node_id, project_root, human_feedback).
template_resolverCallable[[str], str | None] | NoneCallable mapping template-ref names to template text strings. Returns None if the template is not found. Required for template-ref placeholder validation; when absent, template-ref prompts are skipped.
def lint(
construct: Construct,
*,
config: dict[str, Any] | None = None,
known_template_vars: set[str] | None = None,
template_resolver: Callable[[str], str | None] | None = None,
) -> list[LintIssue]:

A list of LintIssue instances. An empty list means all bindings are satisfied.

@dataclass
class LintIssue:
node_name: str # which node has the problem
param: str # which parameter is unresolved
kind: str # "from_input", "from_config", "from_input_model", "from_config_model",
# "template_placeholder_unresolvable", "template_placeholder_known_vars_only"
message: str # human-readable description
required: bool # True if the parameter has no default

The kind field tells you where the parameter was expected to resolve from. Every lint issue kind, its severity, and its meaning are listed in the Kind / Severity / Meaning reference — that table is generated from the library itself (LINT_KIND_META), so it is always complete and never drifts from what lint() actually emits.

For every node with FromInput or FromConfig parameters, lint() checks that the parameter name exists as a key in the config dict:

@node(outputs=Result)
def process(
upstream: Claims,
topic: Annotated[str, FromInput], # lint checks: "topic" in config
limiter: Annotated[RateLimiter, FromConfig], # lint checks: "limiter" in config
) -> Result: ...

When a DI parameter uses a BaseModel subclass, the resolver bundles — it constructs an instance by pulling each model field from config. lint() checks every field individually:

class RunCtx(BaseModel):
node_id: str
project_root: str
@node(outputs=Result)
def process(
upstream: Claims,
ctx: Annotated[RunCtx, FromInput], # lint checks: "node_id" AND "project_root" in config
) -> Result: ...

For nodes with an Oracle modifier whose merge_fn is a @merge_fn-decorated function, lint() also checks the merge function’s DI parameters:

@merge_fn
def weighted_merge(
candidates: list[Result],
weights: Annotated[list[float], FromConfig], # lint checks: "weights" in config
) -> Result: ...

Lint validates placeholders in both inline prompts and template-ref prompts. The two kinds use different placeholder syntax and see different key sets.

Inline prompts use ${var} syntax. A prompt string that contains whitespace or ${ is classified as inline. Lint extracts every ${var} placeholder and validates it against predicted input dict keys only. Flattened fields from render_for_prompt are NOT available. Framework extras (node_id, project_root, human_feedback) are NOT available. This is because inline prompts skip the _render_with_flattening path and the resolver has no config/state access.

Template-ref prompts are bare names like "rw/summarize". Lint uses the template_resolver callable to read the template text, then extracts {placeholder} names (Python str.format syntax). These are validated against predicted input keys plus flattened fields from render_for_prompt plus framework extras. Template-ref prompts go through _render_with_flattening at runtime, so they have access to the full key set.

If no template_resolver is provided, template-ref prompts are silently skipped.

AspectInline ${var}Template-ref {var}
Flattened fieldsNot availableAvailable
Framework extrasNot availableAvailable
render_for_prompt fieldsNot availableAvailable (as top-level vars)

This distinction matters because a placeholder that resolves correctly in a template-ref prompt may fail in an inline prompt, and vice versa.

lint() validates Loop modifier when conditions for common issues that would crash at runtime.

node = Node("refine", ...) | Loop(when="nonexistent", max_iterations=3)
pipeline = Construct("test", nodes=[seed, node])
issues = lint(pipeline)
# LintIssue(kind="loop_condition_unregistered", required=True,
# message="Loop condition 'nonexistent' is not registered...")

The most common Loop bug: lambda d: d.score < 0.8 crashes on the first iteration because the value is None before the node has produced output.

node = Node("refine", ...) | Loop(when=lambda d: d.score < 0.8, max_iterations=3)
issues = lint(pipeline)
# LintIssue(kind="loop_condition_none_unsafe", required=False,
# message="Loop condition raises when called with None...")

Fix: lambda d: d is None or d.score < 0.8

parse_condition("score < 0.8") produces a callable that always crashes on None (it calls getattr(None, "score")). When registered as a Loop condition, lint reports this as ERROR:

from neograph.conditions import parse_condition
conditions = {"score_check": parse_condition("score < 0.8")}
node = Node("refine", ...) | Loop(when="score_check", max_iterations=3)
lint(pipeline, conditions=conditions)
# LintIssue(kind="loop_condition_none_unsafe", required=True, ...)

lint() flags an agent or act node bound to a tool that supports only asynchronous invocation. The canonical case is an MCP tool loaded via langchain-mcp-adapters: it is a StructuredTool backed by a coroutine with no sync func, so calling .invoke() raises NotImplementedError. Such a tool cannot run under the sync run() driver — the graph must be driven with arun().

Because lint() cannot know statically which driver you will use, it reports this as a WARN whenever an async-only tool is bound:

# `search` is a raw async-only BaseTool (e.g. from load_mcp_tools)
node = Node("explore", mode="agent", tools=[search], ...)
pipeline = Construct("mcp", nodes=[node])
issues = lint(pipeline)
# LintIssue(kind="tool_requires_async_driver", required=False,
# message="Node 'explore': tool 'search' is async-only ... Drive this
# graph with arun() so the async tool loop is used.")

Lint resolves the concrete tool object two ways: from a raw BaseTool passed directly in tools= (carried on the synthesized spec), or by instantiating the registered tool_factories= entry. Tool-factory failures are swallowed — a tool lint cannot instantiate simply yields no async-only finding, never a crash. See Raw LangChain tools (and MCP) for the runtime side.

A manifest-driven FromResource(ref=<kind>) parameter (see Resource Hydration) can only resolve if some upstream agent/act node emits a resource_link to populate the manifest. lint() walks the construct for any resource-link-producing node; if a ref-hydrating parameter exists but no such producer does, the manifest is guaranteed empty and hydration would fail loud at runtime, far from the cause.

node = Node("assess", ...) # hydrates FromResource(ref="email-history")
pipeline = Construct("flat", nodes=[seed, node]) # no agent/act producer
issues = lint(pipeline)
# LintIssue(kind="resource_hydration_kind_unmatched", required=True,
# message="Node 'assess': parameter 'history' hydrates manifest
# kind='email-history' but no upstream agent/act node produces
# resource_links...")

This is an ERROR. Per-kind matching is not statically knowable — kinds are lifted at runtime — so lint uses producer existence as the honest static gate. The fix is an upstream resource-link producer, or the flat-server fallback: a templated FromResource(uri=...) or a resource_reader tool.

from typing import Annotated
from pydantic import BaseModel
from neograph import node, construct_from_functions, lint, FromInput, FromConfig
class Claims(BaseModel):
items: list[str]
class Result(BaseModel):
summary: str
@node(outputs=Claims)
def claims() -> Claims:
return Claims(items=["a", "b"])
@node(outputs=Result)
def summarize(
claims: Claims,
topic: Annotated[str, FromInput],
max_len: Annotated[int, FromConfig],
) -> Result:
return Result(summary=f"{topic}: {len(claims.items)} claims (max {max_len})")
pipeline = construct_from_functions("lint-demo", [claims, summarize])
# Check with a complete config -- no issues
issues = lint(pipeline, config={"topic": "AI safety", "max_len": 500})
assert issues == []
# Check with a missing key -- lint reports the gap
issues = lint(pipeline, config={"topic": "AI safety"})
assert len(issues) == 1
assert issues[0].param == "max_len"
assert issues[0].kind == "from_config"

lint() runs automatically as part of the neograph check CLI. See Pipeline Validation for details on the --config and --setup flags that supply config to the lint pass.

The CLI also supports template validation:

  • --known-vars — comma-separated extra template variable names (e.g., --known-vars topic,json_schema). These are merged with framework extras and passed as known_template_vars.
  • --setup module exports — if your setup module (passed via --setup) exports get_template_resolver(), the CLI calls it to obtain a template_resolver callable. If it exports get_known_template_vars(), the CLI calls it and merges the returned names with any --known-vars from the command line.
# my_setup.py — setup module for neograph check --setup my_setup
def get_check_config():
return {"node_id": "test", "project_root": "/tmp"}
def get_template_resolver():
"""Return a callable that maps template names to template text."""
def resolver(name: str) -> str | None:
templates = {"rw/summarize": "Summarize {claims} about {topic}."}
return templates.get(name)
return resolver
def get_known_template_vars():
"""Return extra variable names the consumer bridge provides."""
return {"topic", "json_schema"}

Documentation © 2025-2026 Constantine Mirin, mirin.pro. Licensed under CC BY-ND 4.0.