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.
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.lswanting eza's icons for a human to read). -
bypasses-shadow(name[,name...])— callscommand <name>,builtin <name>, or (forhelpspecifically)__original_help $argv, deliberately forcing stock behavior because the shadow's override would break this function's logic: timestamps leaking into a parsed capture,-iprompting on a path meant to run unattended, structural output changes breaking astring/sedparse, 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:rmfalls back tocommand rmfor any flag except a bare-r,-R, or--recursive(those still route totrash put) — sorm -f/rm -rfqualify, butrm -r $diralone does not.mkdirfalls back tocommand mkdir -pfor 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-limitingsite is one grep away instead of silently wrong. Don't use this forls— eza's long-format/icon layout is structural, not tty-gated, so it stays different from stocklseven piped; a barelscall still needsuses-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/mktempoutput (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'sread(genuinely waiting on a terminal — not astring split | readorwhile readconsuming a pipe, which never blocks), aconfirm-style prompt with no--yes/--force/--silentbypass. 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.