Forthcoming capability
Preview targeted at v0.4.0 Implemented on a branch

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 19 October 2026

  • Not in v0.1.0-beta4. The released CLI has no check command; the roadmap lists it under the canonical CLI redesign (task 3.15.1).
  • Designed. ADR-021 (accepted 30 August 2026) places linting under netsuke check. The rule reference is the source of truth for every rule; the design document is a living document that changes as the prototype is used. Both live on the branch of pull request #621 until it merges, so the links point there. The ADR’s number may change on merge, because main has since taken ADR-021 for another decision.
  • Implemented on a branch. Pull request #621, for 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. It currently conflicts with main and needs a rebase before it can merge.
  • Preview targeted at v0.4.0, as a prototype. Rule names are permanent from v0.4.0. Everything else may still change: rule membership, default severities, flags, and output shapes. A later step of the branch’s manifest-linting phase freezes those contracts once the rules have been used on manifests their authors did not write.

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.

Invocations from the design document
$ 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 code and span label of each JSON finding, 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-inputcorrectnessgraphwarningrecipe names another target's output without depending on it
directory-dep-not-order-onlycachingmanifestwarningdirectory-creating target used as a content dependency
phony-dep-of-file-targetcachingmanifestwarningfile target depends on a phony target through sources or deps
bashismportabilitydocumentwarningrecipe uses a construct /bin/sh does not promise
background-jobdeterminismdocumentwarningrecipe detaches a process with a trailing &
recursive-build-invocationdeterminismdocumentwarningrecipe invokes a build tool
builtin-clean-actionredundancydocumentadviceaction named clean duplicates the built-in netsuke clean
duplicate-rule-reciperedundancymanifestwarningtwo rules declare identical recipes
redundant-alwaysredundancydocumentadvicealways declared on a target that is already phony
redundant-dependencyredundancymanifestadvicepath declared under more than one dependency key
serial-order-without-depsredundancydocumentadvicedependency_order: serial declared with fewer than two deps
unused-macrohygienedocumentwarningdeclared macro that nothing calls
unused-rulehygienemanifestwarningdeclared rule that no target or action references
unused-varhygienedocumentwarningglobal vars entry that no template references
action-without-descriptionclaritydocumentadviceaction declares no description
command-chain-not-listclaritydocumentadvicescalar command chains steps with &&
literal-recipe-pathclaritydocumentwarningrecipe repeats a path the target already declares
rule-without-descriptionclaritydocumentoffrule declares no description
unreachable-targetclaritygraphofftarget reachable from no default and no other target
legacy-placeholdermigrationdocumentwarningrecipe uses the undocumented $in or $out placeholder (see the note below)
manual-ninja-escapemigrationdocumentwarningrecipe doubles a dollar to escape it for Ninja
suppression-without-reasonsuppressiondirectivewarninglint directive states no reason
unknown-suppressionsuppressiondirectivewarninglint directive names a rule that does not exist
unused-suppressionsuppressiondirectiveadvicelint 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 catch stale workarounds. Doubling a dollar ($$PATH) became a bug once Netsuke escaped dollars at the Ninja writer (ADR-014, shipped in v0.1.0-beta3); see markers and shell dollars. On the branch, legacy-placeholder flags $in/$out because they were still substituted during lowering while the users’ guide documented only {{ ins }} and {{ outs }}. Main has since gone further: ADR-034 makes $in and $out plain shell variables in every recipe, so this rule’s rationale will need revising when #621 rebases.

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, and a directive at the end of a line governs that line’s declaration.

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.

netsuke --json check (passing, abridged from the design document; the URL resolves once #621 merges)
{
  "schema_version": 1,
  "generator": { "name": "netsuke", "version": "0.4.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/main/docs/netsuke-linter-rules.md#directory-dep-not-order-only",
        "primary_span": { "line": 24, "column": 7, "snippet": "      - \"{{ build_dir }}\"" }
      }
    ]
  }
}

From the shallow end

The first step is one bare command with nothing to configure. netsuke check loads, expands, renders, and lowers the manifest through the same stages as a build, with impure template helpers disabled; no recipe runs and no build output is created. The branch’s migration note says it plainly: existing manifests need no changes, because checking is read-only. Everything past that first run is optional, and each step builds on the one before.

  1. 1. Run it and read what it says

    No rule defaults to error and the default threshold is error, so on a manifest that compiles the first run reports findings without failing. Convention rules stay quiet: unreachable-target and rule-without-description are off until a selector enables them, because they encode a house style rather than a defect.

  2. 2. Ask why

    netsuke check --explain directory-dep-not-order-only prints one rule’s summary, rationale, and remediation; --explain alone lists the catalogue. The explanation ships in the binary, so it matches the rules that binary runs.

  3. 3. Make it a gate

    --fail-on warning turns warnings into a failing exit code, which is the point at which the linter starts guarding a CI pipeline. --fail-on never goes the other way and makes findings purely informational.

  4. 4. Fix the policy once

    Category and rule selectors narrow in order, and the [cmds.check] table in netsuke.toml records them for the project (see policy and suppression). The table layers through the usual configuration precedence.

  5. 5. Suppress with a reason

    Where a finding is deliberate, a # netsuke-lint: allow directive names the rule and says why. The suppression rules keep those directives honest after the code around them changes.

The deep end. netsuke --json check --explain emits the whole registry as data, which the design calls the catalogue an editor or agent reads to build a rule picker without scraping prose. Together with the per-finding span data, the design judges it sufficient for a language server; no such server is planned in this work.

Accessibility and localization

The linter adds no output mode of its own. Human findings go through the existing diagnostics reporter, so they inherit the project’s colour, emoji, and accessible output policies. The design document practises the same habit: its architecture diagram is preceded by a prose description for screen-reader users.

Framing text: localized now

The command’s subcommand and flag help, threshold message, summary line, and policy errors are localized as usual. The pull request adds check keys to all 35 Fluent catalogues.

Rule prose: English for now

A rule’s summary, rationale, and remediation are not localized in v0.4.0. ADR-021’s reasoning: the prototype’s wording is expected to change, and translating prose before it settles spends the effort twice.

Identifiers: never translated

Rule, category, and severity names and diagnostic codes are values a user types into a configuration file or a suppression comment and a machine matches exactly. They stay untranslated permanently.

Step 12.2 of the branch’s roadmap plans the migration: Fluent keys of the form lint.rule.<name>.summary, .rationale, and .remediation, with the English text kept as the fallback so a catalogue gap degrades to the source locale rather than to an empty diagnostic. Under --locale, a finding’s rule, category, severity, and code are to stay byte-identical across locales, and --explain --json stays in the source locale so an editor’s rule picker gets stable text. Translation follows the project’s localization glossary and style guide, leaving manifest identifiers such as order_only_deps verbatim.

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.

document

Rules over the authored source with spans: shell constructs, placeholders, unused declarations, redundant flags.

manifest

Rules over the expanded, typed manifest: dependency shapes, duplicate recipes, unused rules.

graph

Rules over the lowered build graph: undeclared inputs and unreachable targets.

directive

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 it can neither omit a shipped rule nor document one that does not exist. A future span-preserving YAML rewriter could add autofixes; that is a separate project and not part of this preview.

How the design earns trust

Three tests per rule, at minimum

A positive case that must fire, a negative case that must not, and a suppression case proving the directive silences it — and that removing the reason produces suppression-without-reason. Snapshot tests pin the human rendering and both JSON branches; behavioural tests drive the binary end to end; and the repository’s own example manifests are linted by a test.

Properties over generated manifests

Findings are ordered deterministically; a suppression never reaches outside its node; --limit never changes which findings would have been reported; and a finding’s severity equals the policy for its rule. Two runs over the same manifest and configuration produce byte-identical findings.

Honest spans, loud typos

A wrong span is worse than no span, so the resolver returns nothing rather than guessing. An unknown rule name in a selector is an error, so a typo in CI configuration fails loudly instead of silently disabling nothing.

Evidence before the freeze

The branch roadmap measures value by findings that changed a manifest, not by the number of rules registered. Its first step lints a corpus and records each rule’s disposition before any contract is frozen. Two structural tasks are already done there: moving miette out of the lint core into an adapter, and runner telemetry for netsuke check.