Skip to content

Notes & levels

footman watches how a task treats the process it shares with its siblings. When a task spawns through raw subprocess, writes os.environ directly, or reads a working directory that isn't its own, footman does the safe thing at the site — scopes the write, fills in the spawn's cwd and env, answers honestly — and says so, once, on stderr. Each of those sayings is a note, and every note has a kind: a family plus, where one exists, an instance naming what happened — environ-write:JAVA_HOME, popen-inject:git, lane-wait:serial.

Notes exist because the safe thing footman did may not be the thing the author meant. The note teaches the deliberate spelling; the levels below decide how loudly — up to failing the task, which is what makes a note enforceable in CI rather than a line an agent scrolls past.

The four levels

Every kind carries a level, and a project can reclassify any kind:

  • trace — recorded, printed only under -v. There is no "off": invisible-unless-asked is the mute, and someone who asked to see everything still sees it.
  • info — printed once per task and kind, prefixed info:.
  • warning — the same, prefixed warning:: worth fixing.
  • error — printed as it fires and the task fails at its boundary, listing every banned note with its site.

Whatever printed, every fired note is recorded on its task's row in the --json envelope — trace included, since a machine reading the run wants the record, not the terminal's gating — as {kind, level, site, text} objects under notes.

Two properties make the record useful. Notes dedup per task, kind and instance: a task that sets ten variables gets ten notes, one each — all issues surface in one run, never fix-one-and-see-the-next — while the same variable written in a loop says it once. And every note carries its site: the first stack frame outside footman, as file:line, so five findings are five places to jump to.

Why error fails at the boundary, not at the site

By the time a note fires, footman has already handled the operation safely — nothing is prevented by stopping early, and stopping early would show exactly one issue per run. So an error-classified note lets the body run to completion and fails the task at its boundary with the full list:

error: task deploy sets JAVA_HOME via os.environ — … [tasks.py:41]
error: task deploy spawns via raw subprocess — … [tasks.py:50]
fm: deploy: 2 banned notes: environ-write:JAVA_HOME at tasks.py:41;
popen-inject:git at tasks.py:50 — fix the site, or classify a
known-harmless instance in [tool.footman.notes]

The same bargain --keep-going makes: see everything, then fail honestly.

Classifying kinds: [tool.footman.notes]

The table maps patterns to levels. A key is [task/]kind — either side may be * — and the most specific rule wins, in this order: exact (task, kind), then (task, family), (task, *), (*, kind), (*, family), the "*" blanket, and finally the kind's built-in default. Tasks are their dotted addresses; / appears in neither side, so the spelling cannot collide.

[tool.footman.notes]
"*" = "error"                          # ban everything…
"environ-write:JAVA_HOME" = "info"     # …except this audited variable
"docs.build/popen-inject:dot" = "info" # …and this task's graphviz spawn
"migrate/getcwd" = "info"              # …and one task's cwd reads

Because the specific entries outrank the blanket, this is also the adoption path: run at the defaults and let the warnings surface everything at once; audit each finding by its site; give known-harmless third-party behaviour a pinned entry as narrow as the evidence supports; and only then raise the blanket to error. The audited entries survive the flip, and every new instance — a different variable, a different program, a different task — lands outside them and hits the wall.

An unknown kind in the table is refused by name, so a typo cannot become a wall someone merely believes is up. The instance half of a parameterised kind is runtime data (footman cannot enumerate your variables), so only the family is checked: a misspelled instance silently never matches.

The kinds

kind default fires when
environ-write:<variable> info A task sets a variable via os.environ. footman scoped the write to the task (children see it, siblings don't); say it on purpose with env= or ctx.env.
popen-inject:<program> warning A task spawns via raw subprocess and footman filled in cwd/env from the task context. Prefer run() for capture and reporting, or pass cwd=/env= to make it deliberate.
getcwd info A task reads the process working directory while its own directory is elsewhere — a parallel run never chdirs. cwd() is the directory you want.
fork warning A task forks the process. Forking a threaded process is unsafe (the child can inherit held locks); spawn a subprocess instead.
mp-start warning A task starts multiprocessing workers in-process. They inherit the real environment, not the task's overlay; a tool that parallelises itself loses little in the serial lane — mark the task serial.
global-read:<option> warning A task reads a global option without declaring it — say @task(uses=[...]) so its help and provenance show it.
global-unread:<option> info A task declares a global option in uses= but never read it this run. Read a declared option unconditionally and branch on the value, so a conditional consumer never reads as stale — or prune the declaration if it truly is.
lane-wait:<lane> info A task has been waiting on a sole-ownership lane for more than a couple of seconds; the message names the holder. Visibility, not misuse — a lane wait must never be a silent hang.
recorded-title info title= on a recorded=False call: there is no receipt to label. .opts() merges along a chain, so neither author wrote the contradiction — said once so one of them can resolve it.
pre-record-recorded info pre_record on a recorded=False call: there is no record to review. Same merge shape as recorded-title.
hook-return:<plugin> warning A plugin's pre hook returned a value; a pre hook's return channel is reserved (for a pre that supplies the task's result) — keep per-task state on task.state.

Kinds with an instance dedup and match per instance; the bare kinds are addressable per task (migrate/getcwd), which is the carve-out shape for an audited third-party behaviour that has no finer name.

Two relatives live outside this system on purpose: os.putenv and os.unsetenv are refused outright in a managed task (they bypass even os.environ), and the pre-run advisories — a group default's positional naming a child task, a script block ignored inside a pinned project — teach about circumstances rather than code the author controls.

In tests

Runner results carry the same records: result.results[0].notes is the list of Note objects an invocation fired, levels resolved against whatever config the invocation saw. Asserting a task fires no notes at all is the strictest form of the ban, and needs no config to say it.