Files
fish-config/docs/function-classification-schema.md
rootiest 3414f81cb6 feat(tests): add shadow-classification lint; fix real cp/mv/less bugs
New Phase 1b in tests/run-tests.fish: catches a bare C1-shadowed-command
call in a functions/*.fish body with no matching uses-shadow(name) or
self-limiting(name) in that function's own CLASSIFICATION header. This
is exactly the check discussed after the rm and cd audits -- runtime
auto-unwrapping isn't viable in fish (there's no hook finer than
shadowing itself, and rewriting behavior invisibly at runtime is its
own footgun); a static lint using the CLASSIFICATION tag as the
declared-intentional marker is. Scoped to functions/*.fish only: the
one-function-per-file convention there makes body extraction exact
with no block-depth parser needed.

Added a new self-limiting(name) tag to the schema for the case a bare
call is safe not because the caller did anything, but because the
shadow's own logic already neutralizes the override: rm's and mkdir's
flag checks (verified precisely -- rm falls back to command rm for any
flag except a bare -r/-R/--recursive alone, which still routes to
trash; mkdir falls back to command mkdir -p for any flag, no
exception), and grep/fgrep/egrep/dir/vdir/cat's own tty auto-detection
(--color=auto, and bat's default color behavior -- verified
byte-identical to stock cat when piped, since bat also auto-disables
highlighting on a non-terminal). Explicit and durable rather than a
silent lint exemption: if a shadow's bypass condition is ever
weakened, every self-limiting site is one grep away instead of
silently wrong.

Running the first draft of the lint surfaced three more real bugs,
none previously audited:

- config-help.fish's --man pager path checks `type -q less` (proving
  it wants the real less binary specifically, for less-only -R/+N
  flag syntax) then called it bare, routing through our own
  $PAGER -> ov -> less -> more -> cat fallback chain instead -- which
  could hand those less-specific flags to a completely different
  program. Now command less.
- _fish_deps_install.fish and _fish_deps_update.fish's binary-upgrade
  paths cp a freshly downloaded binary over an already-installed one
  with no existence guard -- the update flow's target is guaranteed to
  already exist. Our cp shadow forces -i unconditionally (a plain
  alias, not flag-aware like rm's), so this would hang waiting on a
  confirmation prompt in any non-interactive run. Now command cp.
  Same two files' lazydocker install path piped curl output into bare
  bash, invoking our shell-switch wrapper instead of a plain
  subshell. Now command bash.
- agents-init.fish's AGENTS.md/CLAUDE.md relocation calls mv bare in
  four places; each is already guarded by a preceding test -f check on
  the destination, so the -i alias was unlikely to ever fire in
  practice, but explicit command mv removes the reliance on that guard
  entirely rather than leaving it as the only thing standing between a
  file move and an unattended hang.

The remaining ~65 flagged call sites across ~24 files were reviewed
individually and tagged self-limiting(rm)/self-limiting(mkdir)
(verified flagged with -f/-rf or -p) and self-limiting(grep)/
self-limiting(cat) (verified piped, captured, or -q/-c; none display
color to a human), plus uses-shadow(ls) for two existence-check-only
calls (cffetch.fish, ffetch.fish) whose output is redirected to
/dev/null.
2026-09-21 21:26:55 -04:00

5.3 KiB

Function CLASSIFICATION schema

This is the canonical definition of the # CLASSIFICATION function doc-header label. It's referenced from code comments and commit messages — link here, not to anything under AGENTS/ (that tree is git-ignored local agent state, not part of the repo).

See Public function documentation header in CONTRIBUTING.md for where CLASSIFICATION fits among the other header labels, and C1 — Command Shadows for the full list of C1-shadowed commands this schema's shadow tags refer to.

Format

Optional. Comma-separated tags from the closed set below, on the indented body line directly under the label:

# CLASSIFICATION
#   uses-shadow(ls), destructive

Omit the label entirely when nothing applies — omission means "nothing to flag," not "not yet audited," so don't add it speculatively, and don't add it empty as a placeholder.

Tags

  • uses-shadow(name[,name...]) — calls a C1-shadowed command (see the C1 doc linked above) bare, deliberately wanting the overridden behavior (e.g. ls wanting eza's icons for a human to read).

  • bypasses-shadow(name[,name...]) — calls command <name>, builtin <name>, or (for help specifically) __original_help $argv, deliberately forcing stock behavior because the shadow's override would break this function's logic: timestamps leaking into a parsed capture, -i prompting on a path meant to run unattended, structural output changes breaking a string/sed parse, etc.

  • self-limiting(name[,name...]) — calls a shadowed command bare, and it's safe not because the caller did anything but because the shadow's own logic already neutralizes the override for this call. Verify the actual condition per shadow, it's not the same check for each one:

    • rm falls back to command rm for any flag except a bare -r, -R, or --recursive (those still route to trash put) — so rm -f/rm -rf qualify, but rm -r $dir alone does not.
    • mkdir falls back to command mkdir -p for any flag at all, no exception.
    • --color=auto/bat's own tty auto-detection (grep, fgrep, egrep, dir, vdir, cat — verified byte-identical to stock when piped or captured, since none of these force color on a non-terminal).

    Document it explicitly rather than leaving the bare call untagged: if a shadow's bypass condition is ever weakened, narrowed, or removed, every self-limiting site is one grep away instead of silently wrong. Don't use this for ls — eza's long-format/icon layout is structural, not tty-gated, so it stays different from stock ls even piped; a bare ls call still needs uses-shadow(ls) or a real bypass.

  • destructive — can irreversibly delete or overwrite data: rm -f, rm -rf, truncating or force-overwriting a file, git push --force. Routine cleanup of the function's own $tmpdir/$_tmpdir/mktemp output (or other output it just created in this same call) is expected behavior, not a hazard — don't tag it.

  • network — makes an outbound network call: curl, wget, ssh, git fetch/pull/push/clone, paru/yay (package-manager network ops), talking to an API, etc.

  • blocking-prompt — can block waiting on interactive confirmation with no non-interactive escape hatch: a shadow's forced -i, fish's read (genuinely waiting on a terminal — not a string split | read or while read consuming a pipe, which never blocks), a confirm-style prompt with no --yes/--force/--silent bypass. Don't tag a function that's only ever meant to be run interactively at a prompt (a keybinding handler, an fzf-driven picker) — the hazard this tag exists for is a script or another function calling it unexpectedly, not a human running it themselves.

Placement

Directly under # DEPENDENCIES if the header has one; otherwise directly under # COMPONENT; otherwise directly under # CATEGORY; otherwise as the first label in the header block (this is the common case for internal _-prefixed helpers, which usually carry none of the three).

Judgment calls

uses-shadow vs bypasses-shadow is the easiest place to get subtly wrong — verify against the actual code, not just whether the name appears in the file. A function that only calls a helper which itself interacts with a shadow does not get the tag; the tag belongs on the helper. When generating these tags in bulk (e.g. delegating the sweep to another model), review every result against the source before trusting it — this schema's own rollout caught several false positives this way: a piped read misread as an interactive prompt, a documented --yes flag missed as an escape hatch, and cleanup of a function's own temp output flagged as destructive despite the explicit exclusion above.

rm specifically has its own internal flag check (any flag other than -r/-R/--recursive falls back to command rm inside the shadow itself, before it ever touches trash) — a caller writing plain rm -f or rm -rf is not bypassing anything itself, the shadow is. Only tag bypasses-shadow(rm) when the caller explicitly writes command rm or builtin rm; a bare rm -f/rm -rf call gets no shadow tag at all.