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}")Parameters
Section titled “Parameters”| Parameter | Type | Description |
|---|---|---|
construct | Construct | The pipeline to check |
config | dict[str, Any] | None | Config 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_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”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: ...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.
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) |
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.