Static Linting (lint)
lint() walks every node in a Construct, checks its DI bindings, and checks that prompt placeholders resolve to known input keys. It never raises — it returns a list of LintIssue instances describing what it found.
lint() needs no config. A graph checker should not have to be told what the world looks like to do its job, because when it does, its agreement with that description is not evidence.
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}")Parameters
Section titled “Parameters”| Parameter | Type | Description |
|---|---|---|
construct | Construct | The pipeline to check |
config | dict[str, Any] | None | Optional. A specific caller payload to check the graph against. When None, lint() reports the graph’s input contract instead of demanding one. |
known_template_vars | set[str] | None | Set of extra variable names the consumer’s prompt pipeline provides. Merged with framework extras (node_id, project_root, human_feedback). |
template_resolver | Callable[[str], str | None] | None | Callable 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. |
Signature
Section titled “Signature”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]:Return value
Section titled “Return value”A list of LintIssue instances. An empty list means all bindings are satisfied.
LintIssue fields
Section titled “LintIssue fields”@dataclassclass 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 defaultThe 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.
What it checks
Section titled “What it checks”Per-parameter DI bindings
Section titled “Per-parameter DI bindings”Two kinds of DI parameter
Section titled “Two kinds of DI parameter”A FromInput or FromConfig parameter falls into one of two categories, and lint() reports them differently:
- Supplied by the caller at runtime — a question, a deal id, an auth handle. These are the graph’s input contract. With no config,
lint()reports them as informational: knowing what a caller must supply is useful, and reporting it as an error only says that the graph has inputs. - Unsatisfiable by construction — a parameter whose value is the
Each-fanned item or theLoopcarry. No caller can supply these, solint()reportsfrom_input_unsatisfiableas an ERROR, with no config needed.
The second check reads the construct’s structure and never reads a config, so no fixture can silence it. That matters: a lint config holding a key no caller could pass is not a fixture, it is the gate grading its own answer key. One such padded config hid an unsatisfiable binding, and the pipeline then fanned out correctly, keyed its results correctly, and computed every one of them from the padded placeholder value.
Bind a fanned item or a loop carry as a port parameter instead: drop the DI marker and type the parameter as the sub-construct’s input=.
Supply-side checks
Section titled “Supply-side checks”lint() also checks the direction most linters miss: not only that every demand has a source, but that every supply has a consumer.
template_input_unreferenced— an LLM-mode node binds an input, a DI parameter, or a context field that its own template never names. The value reaches the node and the model never sees it.output_field_unconsumed— a node produces a field that nothing reads: no downstream node takes the model, no template reads it by name, and it is not the graph’s terminal output.
Both are WARN. Demand is read from template text, so a prompt_compiler that composes the final message can consume a name the template never spells.
Checking a specific payload
Section titled “Checking a specific payload”Pass config= when you want a different question answered: does this caller payload satisfy this graph. Then lint() checks each parameter name against the keys you supplied:
@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: ...Bundled BaseModel fields
Section titled “Bundled BaseModel fields”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: ...merge_fn DI bindings
Section titled “merge_fn DI bindings”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_fndef weighted_merge( candidates: list[Result], weights: Annotated[list[float], FromConfig], # lint checks: "weights" in config) -> Result: ...Template placeholder validation
Section titled “Template placeholder validation”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.
Two further columns are opt-in, and the opt-in belongs to your prompt compiler. The seam offers a node’s resolved FromInput/FromConfig values as di_inputs and its declared context= fields as context, but a compiler only receives a channel it declares (or absorbs via **kwargs). So lint accepts {domain} for a domain: Annotated[str, FromInput] parameter, and {brief} for context=["brief"], only when the compiler you pass to lint(..., prompt_compiler=...) declares that parameter. Pass a compiler that declares neither and the placeholder is genuinely unresolvable at runtime — so lint still reports it, which is the correct answer for that compiler.
If no template_resolver is provided, template-ref prompts are silently skipped.
Inline vs template-ref key differences
Section titled “Inline vs template-ref key differences”| Aspect | Inline ${var} | Template-ref {var} |
|---|---|---|
| Flattened fields | Not available | Available |
| Framework extras | Not available | Available |
render_for_prompt fields | Not available | Available (as top-level vars) |
DI param names (FromInput/FromConfig) | Not available | Available iff the compiler declares di_inputs |
Declared context= field names | Not available | Available iff the compiler declares context |
This distinction matters because a placeholder that resolves correctly in a template-ref prompt may fail in an inline prompt, and vice versa.
Loop condition checks
Section titled “Loop condition checks”lint() validates Loop modifier when conditions for common issues that would crash at runtime.
Unregistered string condition
Section titled “Unregistered string condition”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...")None-unsafe callable
Section titled “None-unsafe callable”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
String conditions from parse_condition
Section titled “String conditions from parse_condition”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, ...)Async-only tool checks
Section titled “Async-only tool checks”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.
Resource hydration manifest checks
Section titled “Resource hydration manifest checks”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 producerissues = 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.
Example: full lint workflow
Section titled “Example: full lint workflow”from typing import Annotatedfrom pydantic import BaseModelfrom 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 issuesissues = lint(pipeline, config={"topic": "AI safety", "max_len": 500})assert issues == []
# Check with a missing key -- lint reports the gapissues = lint(pipeline, config={"topic": "AI safety"})assert len(issues) == 1assert issues[0].param == "max_len"assert issues[0].kind == "from_config"Using with neograph check
Section titled “Using with neograph check”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 asknown_template_vars.--setupmodule exports — if your setup module (passed via--setup) exportsget_template_resolver(), the CLI calls it to obtain atemplate_resolvercallable. If it exportsget_known_template_vars(), the CLI calls it and merges the returned names with any--known-varsfrom the command line.
# my_setup.py — setup module for neograph check --setup my_setupdef 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.