magus v0.4.2 is out. See what's new
¶ View markdown source · ✎ Suggest an edit
6 min read

Formatting, linting, and generated files

Formatting is not linting

These are different operations. Much tooling blurs them, and the blur is the source of a whole family of avoidable problems.

Formatting Linting
What it computes a canonical form of the same program judgments about the program
Output bytes findings
Can it disagree? no, once a normal form is chosen yes, that is the point
Safe to apply unattended yes no
Who acts on it nobody, it is mechanical a human, per finding

A formatter is a total function on syntax: feed it a valid file, get back the one canonical spelling of that same program. There is no judgment in it and nothing to review. A linter is the opposite: every finding is a claim that a human should decide about, and a fix may change what the program means.

magus keeps them apart. Formatting is format (report by default, apply under the rw charm); linting is lint. A spell exposes them as separate ops even when one binary implements both.

Take this seriously even when your tool does not. A tool that reports "this file is not formatted" as a lint finding has turned a mechanical fact into a judgment, and a tool whose one --fix flag both reformats and rewrites semantics has made the safe operation inseparable from the unsafe one. When your toolchain conflates them, split them back apart at the op boundary: one op that formats, one that lints, so a target can compose them independently.

Generated files are where the conflation breaks

Ask what each should do with generated output and you get opposite answers. That is the clearest demonstration that they are not the same operation, and a toolchain that cannot express both answers separately cannot get this right.

Linters: never gate on generated output

A lint finding asks a human to change a line. Nobody can act on that in a generated file: the fix belongs in the generator, and the next regeneration overwrites whatever the reader does. A gate that fires where its reader has no legal move is one people learn to route around.

Exclude generated trees from linters, and say why at the exclusion:

# Generated Markdown. Drift-gated by its own generators, not hand-authored, so a
# prose linter must not gate it - a rule fix belongs in the generator, not the output.
**/gen/

This is about actionability, not drift. It holds whether or not the generator happens to emit lint-clean output.

Formatters: cover generated output

The instinct is to exclude it here too. Do the opposite, provided one thing is true:

The generator emits formatter-normal output.

When that holds, running the formatter over generated files is a no-op, so the two cannot disagree and no drift is possible. When it does not, they oscillate: the formatter rewrites the file, the next generate puts it back, and neither gate catches it because each passes on its own.

Make it hold by putting the formatter inside the generator, as the last step before the write, rather than running it over the output afterward. A Go generator calls format.Source; the same idea applies to any language with a canonical form.

Once that is true, covering generated files is strictly better than excluding them, because the format check becomes the guarantee. A generator that stops emitting normal form fails the check by name, with the ordinary fix. Exclude it and you have removed the only thing that would have told you.

Generators must be byte-stable

Same generator version, same inputs, identical bytes. No exceptions.

The output must not vary with run count, machine, wall-clock time, filesystem walk order, map iteration order, parallelism, locale, or absolute paths. If it does, the generator is broken, and the fix is in the generator every time.

This is not a preference. Four things magus does are only sound if it holds:

  • Drift gating. A gate compares regenerated output against what is committed. A generator that emits different bytes for the same inputs makes that gate report noise, and a gate that cries wolf is one people route around.
  • Caching. A cache hit replays stored bytes instead of running the target. If rerunning would have produced something else, the cache is lying.
  • Merge resolution. magus resolves a conflict in generated output by regenerating. That only converges if regeneration is deterministic.
  • Review. A diff in a generated file should mean something changed upstream. If it can also mean nothing, every such diff has to be investigated to find out which.

What is not a violation: output that changes because the generator changed, or because a declared input changed. Those are the mechanism working. The invariant is about a fixed generator and fixed inputs.

Usual causes, all fixable:

Cause Fix
map iteration order sort keys before emitting
timestamps omit, or derive from a declared input
absolute paths emit workspace-relative
filesystem walk order sort the walk results
parallel workers appending collect, then sort
unpinned tool version pin it; the cache keys tool: lines already
random or sequential IDs derive from content

Check that your check can fail

That guarantee is worth exactly as much as the check's exit code, and one very common formatter cannot fail at all:

$ gofmt -l .        # lists every unformatted file
bad.go
$ echo $?
0

gofmt -l reports on stdout and exits 0. magus reads an op's verdict from its exit code, so an unformatted file prints its own name and the run passes green. Most modern check tools (prettier --check, biome check, markdownlint, ruff format --check) signal through the exit code; older ones often do not.

Before you rely on any tool as a gate, run it against a deliberately broken file and check $?. A check that cannot fail is worse than no check, because it looks like coverage.

Declared outputs answer "is this generated"

A target declares what it writes:

export fun index_generate(ctx: magus\Context, args: [str]) > void {
    ctx.writesFiles("MAGUS.md");
    ...
}

That declaration is the single source of truth. magus describe file answers "is this generated, and what produces it" from it, magus clean --outputs unions it across every target, and magus writes the same globs into .gitattributes so a merge of generated output resolves by regenerating instead of by hand.

Anything else needing the same answer should derive it from there. A second copy in an ignore file is a list that rots silently, because nothing checks it against the tree.

Adding a generated artifact

  1. Produce it from generate, not build - committing the output is what makes the drift gate able to check it. See generate or build? in targets.
  2. Declare it with ctx.writesFiles, so magus, .gitattributes, and magus clean all learn about it from one place.
  3. Make the generator emit formatter-normal output.
  4. Leave it in the formatter's scope. Do not add an ignore entry.
  5. Add it to the linters' ignore, with a comment saying the fix belongs upstream.
  6. Do not write a test that re-runs the generator and byte-compares. The generate target already drift-gates every generated file in the workspace; a per-artifact copy of that check is redundant and drifts from the real directive.

magus's own repository applies all of this, including one place where it deliberately does not. See Generated files in this repository in the contributor docs for the worked example.

generatedoutputsformatlintdriftignore-filescodegen
Last updated (94bfdf0b)
Earlier changes on this page (1)

Full history ↗ · Blame source ↗

Glossary

Workspace

The magus root directory that owns a set of projects and shared config; the unit magus operates over. See workspace.

Target

A named operation (build, test, ...) you invoke with magus run <target>; it may compose a spell's tool-native operations and depend on other targets. See targets.

Op

A single tool-native command a target composes (long form: operation); the middle of the work hierarchy (Spell to Op to Target). See operations.

Spell

A language/runtime adapter (e.g. go, md) that maps generic targets onto a toolchain's real commands. See spells.

Charm

An execution modifier attached with : (lint:rw) that changes how a target runs, not which one; the built-in rw flips a check-only target to mutate in place, and ci always strips it. See charms.

Ward

A coded diagnostic that inspects a resolved op and nudges or blocks an anti-pattern before it runs. See wards.

Buzz

The language magusfiles are written in (the .buzz engine). See engines.

Cache

The content-addressed store magus consults before running a target, so unchanged work is skipped. See cache.

CI

An ordinary magusfile-defined target you compose yourself with magus\needs - magus does not hardcode its stages. Magus.RunCI treats it specially only in that it strips the rw charm, it is the anchor magus affected ci keys off, and a selected scope with no project declaring it is a load error rather than a silent no-op. See targets.

MAGUS.md

The committed routing index at a workspace root, regenerated from the knowledge graph: it lists every node and points at the exact query for a given question, so it is the entry point an agent reads first. See knowledge.

Conventions

This page uses none of the site's convention markers. The full set is on the conventions page.