Netsukefile Linter
A manifest that parses, lowers, and produces a valid build.ninja can still be a bad manifest: it rebuilds
everything every time, breaks where /bin/sh is not bash, or races on an undeclared edge.
netsuke check polices the band between “parses” and “provably wrong” with a semantic
analysis over Netsuke's own compiler stages, inspired by mbake.
Status on 3 September 2026
- Not in v0.1.0-beta3. The released CLI has no
checkcommand; the roadmap lists it under the canonical CLI redesign. - Designed. ADR-018 (accepted 30 August 2026) places linting under
netsuke check; the design document and rule reference are normative. They live on the #592 branch until it merges, so the links here point at that branch. - Implemented on a branch. Issue #592 carries the command, all twenty-four rules, the suppression scanner, and contract tests that check the rule reference against the registry in both directions.
- Preview targeted at v0.2.0. Rule names are intended to be permanent from the release that ships them; flags and output shapes may still change before then.
Why a linter, and why under check
Netsuke already rejects provable errors: missing rules, duplicate outputs, cycles, and Ninja text it cannot emit. The linter covers what compiles but misbehaves. It is a set of named rules over the compiler's own artefacts — the authored source with its spans, the expanded manifest, and the lowered build graph — not a text pass over YAML, so a rule can tell an order-only directory dependency from a content dependency, or recognize that a literal path in a recipe is another target's output.
Prior art
mbake confirmed the formatting-versus-semantic split, and its per-rule Boolean configuration and GNU error format shaped the policy table and compact human output. hadolint contributed stable identifiers, per-rule severity, and the failure threshold; buildifier the statement-scoped disable comment; ShellCheck the per-rule URL; Clippy and Ruff the idea that category is metadata and machine output is first-class.
No new noun
check was already reserved in the canonical command vocabulary and listed as unbuilt work. A separate lint command would be a synonym the CLI design forbids, and generate --lint would bury findings behind a generation flag. The linter is netsuke check.
Constraints
Non-interactive and deterministic: no prompts, network, clock, or terminal dependence, so two runs over the same manifest and policy produce byte-identical findings in a fixed order. No reparsing. No duplication of a hard error. Bounded output. And findings never mutate anything: there is no --fix; automated rewriting of an executable manifest is a separate project.
The command
netsuke check analyses the manifest selected by the existing root --file and --directory
flags, and adds four flags of its own. Machine output uses the global --json, not a check-specific flag.
$ netsuke check
$ netsuke check --explain
$ netsuke check --explain directory-dep-not-order-only
$ netsuke check --rule clarity=off --rule literal-recipe-path=error --fail-on warning
$ netsuke --json check
| Flag | Purpose | Default |
|---|---|---|
| --rule <NAME=SEVERITY> | Repeatable policy selector. NAME is a rule or a category; SEVERITY is off, advice, warning, or error. Selectors apply in order, so a category selector followed by a rule selector narrows it. An unknown name is an error, not a warning. |
none |
| --fail-on <SEVERITY> | Threshold at which findings fail the command: error, warning, advice, or never. |
error |
| --limit <N> | Maximum findings reported, keeping the first in source order; 0 reports everything. The verdict is decided before bounding, so truncation never changes the exit code. |
200 |
| --explain [NAME] | Print the rule reference for one rule, or the whole catalogue, instead of analysing. With --json the registry is emitted as result.rules so an editor or agent can build a rule picker without scraping prose. |
— |
Exit codes. 0 when no finding reaches the threshold, 1 when one does. A failure to analyse — missing manifest, parse error, unknown rule name — is an ordinary command error and also exits 1 with a diagnostic document. Separating those classes waits on the roadmap's exit-code taxonomy.
Rule catalogue
Twenty-four rules across nine categories. A rule is a stable, self-describing kebab-case name that is unique across every
stage and category; the name is what appears in policy selectors, suppression directives, --explain, and the
rule field of JSON output, and it never changes. Category is metadata, so recategorizing a rule cannot
invalidate a configuration file. Each finding also carries a diagnostic code of the form
netsuke::lint::<name_in_snake_case> and a url into the rule reference.
| Rule | Category | Stage | Default | Detects |
|---|---|---|---|---|
| undeclared-target-input | correctness | graph | warning | recipe names another target's output without depending on it |
| directory-dep-not-order-only | caching | manifest | warning | directory-creating target used as a content dependency |
| phony-dep-of-file-target | caching | manifest | warning | file target depends on a phony target through sources or deps |
| bashism | portability | document | warning | recipe uses a construct /bin/sh does not promise |
| background-job | determinism | document | warning | recipe detaches a process with a trailing & |
| recursive-build-invocation | determinism | document | warning | recipe invokes a build tool |
| builtin-clean-action | redundancy | document | advice | action named clean duplicates the built-in netsuke clean |
| duplicate-rule-recipe | redundancy | manifest | warning | two rules declare identical recipes |
| redundant-always | redundancy | document | advice | always declared on a target that is already phony |
| redundant-dependency | redundancy | manifest | advice | path declared under more than one dependency key |
| serial-order-without-deps | redundancy | document | advice | dependency_order: serial declared with fewer than two deps |
| unused-macro | hygiene | document | warning | declared macro that nothing calls |
| unused-rule | hygiene | manifest | warning | declared rule that no target or action references |
| unused-var | hygiene | document | warning | global vars entry that no template references |
| action-without-description | clarity | document | advice | action declares no description |
| command-chain-not-list | clarity | document | advice | scalar command chains steps with && |
| literal-recipe-path | clarity | document | warning | recipe repeats a path the target already declares |
| rule-without-description | clarity | document | off | rule declares no description |
| unreachable-target | clarity | graph | off | target reachable from no default and no other target |
| legacy-placeholder | migration | document | warning | recipe uses the undocumented $in or $out placeholder |
| manual-ninja-escape | migration | document | warning | recipe doubles a dollar to escape it for Ninja |
| suppression-without-reason | suppression | directive | warning | lint directive states no reason |
| unknown-suppression | suppression | directive | warning | lint directive names a rule that does not exist |
| unused-suppression | suppression | directive | advice | lint directive suppressed no finding |
Rules default to off only when they encode a project convention rather than a defect: building a target by name without declaring it a default is a legitimate workflow, and rule descriptions are a house style. The two migration rules exist because v0.1.0-beta3 made $$ escaping and the $in/$out placeholders obsolete; see markers and shell dollars.
One rule, worked
undeclared-target-input: Ninja schedules edges concurrently unless a dependency orders them. A recipe that reads a path another target produces, without declaring it, races — it succeeds whenever the producer happened to run first. Serial local builds hide this; a parallel or clean build does not.
Reported
targets:
- name: app
command: "cc build/main.o -o {{ outs }}"
- name: build/main.o
command: "cc -c src/main.c -o {{ outs }}"
Fixed
targets:
- name: app
sources: build/main.o
command: "cc {{ ins }} -o {{ outs }}"
- name: build/main.o
command: "cc -c src/main.c -o {{ outs }}"
Remediation, as the rule reference states it: declare the path under sources if the recipe reads it, or under deps if it only needs it to exist.
Policy and suppression
Project policy
Both policy flags layer through the existing configuration precedence, so a project can fix its policy in netsuke.toml. Setting a severity on an off-by-default rule enables it; setting off on any rule disables it. Nothing about policy resolution consults the environment beyond that chain, reads the terminal, or varies with time.
[cmds.check]
rule = ["clarity=off", "unreachable-target=warning"]
fail_on = "warning"
Suppression directives
Suppression is narrow by construction: a directive names one or more rules and must state a reason. There is no blanket disable comment and no all selector. Scoping follows YAML indentation: a directive alone on its line governs the next declaration and everything indented beneath it.
targets:
# netsuke-lint: allow background-job -- the previewer is intentionally detached
- name: preview
script: |
feh processed &
Directive grammar
# netsuke-lint: allow <rule>[, <rule>…] -- <reason>suppresses the named rules within one node.# netsuke-lint-file: allow <rule>[, <rule>…] -- <reason>suppresses them for the whole file, for findings that cannot be resolved to a span.
A # inside a quoted or block scalar is content, not a directive: the scanner consults the span index, which is why shell comments inside script: | blocks cannot accidentally disable rules. Three rules police the directives themselves — unknown-suppression, suppression-without-reason, and unused-suppression — so a suppression cannot rot silently after the underlying problem is fixed.
Output
Human findings render through the existing diagnostics reporter, so they inherit source snippets and the project's colour,
emoji, and accessibility policies, followed by a summary line stating the count at each severity and any truncation.
With --json the command emits exactly one document in the shared envelope. When no finding reaches the
threshold, a result document goes to stdout; when one does, a diagnostic document goes to stderr with the same finding
objects under diagnostics[0].related, preserving the envelope invariant every other command follows.
{
"schema_version": 1,
"generator": { "name": "netsuke", "version": "0.2.0" },
"result": {
"command": "check",
"status": "pass",
"fail_on": "error",
"summary": { "error": 0, "warning": 2, "advice": 1,
"reported": 3, "suppressed": 1, "omitted": 0 },
"truncated": false,
"findings": [
{
"message": "depends on the directory `build` through `deps`",
"code": "netsuke::lint::directory_dep_not_order_only",
"severity": "warning",
"help": "Move the directory to `order_only_deps`, which guarantees it exists first without tracking its timestamp.",
"url": "https://github.com/leynos/netsuke/blob/issue-592-v0-4-0-design-and-implement-a-netsukefile-linter-inspired-by-mbake/docs/netsuke-linter-rules.md#directory-dep-not-order-only",
"primary_span": { "line": 24, "column": 7, "snippet": " - \"{{ build_dir }}\"" }
}
]
}
}
How it works
The linter binds to four points in the pipeline described in How Netsuke Works. Manifest source flows into a span index and, in parallel, through the existing compiler stages; each lint stage consumes the artefact produced immediately above it, and all four feed one ordered finding sink.
Rules over the authored source with spans: shell constructs, placeholders, unused declarations, redundant flags.
Rules over the expanded, typed manifest: dependency shapes, duplicate recipes, unused rules.
Rules over the lowered build graph: undeclared inputs and unreachable targets.
Rules over the suppression directives themselves.
A rule states what it found and where; the engine stamps the severity resolved from policy, so no rule can decide how loudly to speak. Every rule carries a summary, a rationale, and a remediation in its registry entry, and a contract test checks the rule reference against that registry in both directions, so the documentation is provably complete rather than aspirationally so. A future span-preserving YAML rewriter could add autofixes; that is a separate project and not part of this preview.