diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 05903bb..8d2c9c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -391,6 +391,7 @@ all optional except where noted: | `CATEGORY` | **Required to appear in the manual at all** — see below. | | `COMPONENT` | Only for functions gated by the [opinionated-component system](#opinionated-components). | | `DEPENDENCIES` | Other functions this one calls that a reader may want to look up. | +| `CLASSIFICATION` | Hazard/shadow-interaction tags — see below. | | `SYNOPSIS` | One-line usage form. | | `DESCRIPTION` | Prose description; can span multiple paragraphs. | | `ARGUMENTS` | Flags/positional args, one per line. | @@ -414,6 +415,9 @@ A full example (`functions/claude.fish`): # DEPENDENCIES # agents-init # +# CLASSIFICATION +# bypasses-shadow(claude) +# # SYNOPSIS # claude [ARGS...] # @@ -443,6 +447,14 @@ If your function genuinely doesn't fit any of these, add a new `docs/manual/05-functions/NN-your-category.md` stub (with frontmatter matching its siblings) rather than force-fitting it into an existing one. +**`CLASSIFICATION` flags hazards and shadow interactions, optional and +omitted when nothing applies:** whether the function calls a +[C1-shadowed command](docs/manual/08-components-reference/01-c1-command-shadows.md) +bare wanting the override (`uses-shadow(ls)`) or bypasses it deliberately +via `command`/`builtin` (`bypasses-shadow(cat)`), and general hazards — +`destructive`, `network`, `blocking-prompt`. Full tag definitions and +placement rule: [`docs/function-classification-schema.md`](docs/function-classification-schema.md). + ### Private/internal helper functions Functions named with a leading `_` (e.g. `_agents_init_ensure_gitignore`, diff --git a/conf.d/auto-pull.fish b/conf.d/auto-pull.fish index db4f7f5..67b354a 100644 --- a/conf.d/auto-pull.fish +++ b/conf.d/auto-pull.fish @@ -24,6 +24,9 @@ __fish_config_op_enabled (status basename); or exit # COMPONENT # autoexec/sync # +# CLASSIFICATION +# bypasses-shadow(cat) +# # SYNOPSIS # __auto_pull_on_pwd (event handler, --on-variable PWD) # diff --git a/conf.d/tricks.fish b/conf.d/tricks.fish index 6c82fb1..43f8705 100644 --- a/conf.d/tricks.fish +++ b/conf.d/tricks.fish @@ -97,11 +97,13 @@ if __fish_config_op_enabled (status basename) tricks-bang end end -# Fish command history override to show timestamps -# Shadowing the history command is opinionated (C1 aliasing); when disabled, -# the function is never defined and fish's stock history behavior applies. +# Timestamped history view. Named pretty-history (not history) so it never +# shadows the fish builtin -- every function in this config that expects +# stock `history` semantics (search, --max, merge, ...) would otherwise +# silently break, which has happened more than once. Opinionated (C1 +# aliasing); when disabled, the function is never defined. if __fish_config_op_enabled (status basename) aliases-tricks - function history + function pretty-history --description 'History with timestamps prepended to every entry' builtin history --show-time='%F %T ' end end diff --git a/docs/build-manual.py b/docs/build-manual.py index e80ebca..cb7ac5f 100644 --- a/docs/build-manual.py +++ b/docs/build-manual.py @@ -703,6 +703,29 @@ ENTRY_HEADS = { } +def _classification_tags(raw: list[str]) -> list[str]: + """Split a CLASSIFICATION body into its comma-separated tags. + + A plain comma split (as `names()` uses for DEPENDENCIES) would break on + the commas inside `uses-shadow(rm, cp)`-style tags, so this only splits + on commas at paren depth 0. + """ + text = " ".join(raw) + tags: list[str] = [] + depth = 0 + start = 0 + for i, ch in enumerate(text): + if ch == "(": + depth += 1 + elif ch == ")": + depth = max(0, depth - 1) + elif ch == "," and depth == 0: + tags.append(text[start:i].strip()) + start = i + 1 + tags.append(text[start:].strip()) + return [t for t in tags if t] + + def render_entry(fn: dict[str, list[str]], used_by: list[str], link=None) -> str: """Render one parsed function header as a manual entry body. @@ -739,6 +762,7 @@ def render_entry(fn: dict[str, list[str]], used_by: list[str], link=None) -> str refs = [] for label, values in ( ("Dependencies", names(fn.get("DEPENDENCIES", []))), + ("Classification", _classification_tags(fn.get("CLASSIFICATION", []))), ("Used by", sorted(used_by)), ): if values: @@ -884,6 +908,7 @@ def render_entry_site(fn: dict[str, list[str]], used_by: list[str], link=None) - refs = [] for label, values in ( ("Dependencies", names(fn.get("DEPENDENCIES", []))), + ("Classification", _classification_tags(fn.get("CLASSIFICATION", []))), ("Used by", sorted(used_by)), ): if values: diff --git a/docs/fish-config.index b/docs/fish-config.index index 3b398dc..4d0c204 100644 --- a/docs/fish-config.index +++ b/docs/fish-config.index @@ -291,6 +291,8 @@ network-shadow=### network monitor=### monitor shell-tools=### shell-tools dev-tools=### dev-tools +classification=### For function authors +shadow-classification=### For function authors c2=## C2 — Startup Side-Effects autoexec=## C2 — Startup Side-Effects startup=## C2 — Startup Side-Effects diff --git a/docs/function-classification-schema.md b/docs/function-classification-schema.md new file mode 100644 index 0000000..9631b44 --- /dev/null +++ b/docs/function-classification-schema.md @@ -0,0 +1,101 @@ +# 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](../CONTRIBUTING.md#public-function-documentation-header) +in `CONTRIBUTING.md` for where `CLASSIFICATION` fits among the other header +labels, and [C1 — Command Shadows](manual/08-components-reference/01-c1-command-shadows.md) +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: + +```fish +# 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 `, + `builtin `, 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. diff --git a/docs/manual/08-components-reference/01-c1-command-shadows.md b/docs/manual/08-components-reference/01-c1-command-shadows.md index eb3b5fb..87356a7 100644 --- a/docs/manual/08-components-reference/01-c1-command-shadows.md +++ b/docs/manual/08-components-reference/01-c1-command-shadows.md @@ -19,7 +19,6 @@ all of these commands. rg rg --hyperlink-format=kitty system rg mkdir verbose path-tree display on creation mkdir -p silently bash XDG bashrc + $SHELL reset on exit system bash - history timestamps prepended to every entry fish builtin history cp / mv forced -i confirmation prompt cp / mv unmodified wget forced --continue (resume downloads) system wget grep/fgrep/egrep forced --color=auto system grep variants @@ -31,6 +30,11 @@ all of these commands. When C1 is disabled, `rm` uses bare `command rm` with no wrapper — files are permanently deleted, not trashed. There is no intermediate safety net. +`history` itself is never shadowed — every function in this config that +reads history depends on its stock builtin semantics. `pretty-history` +(same `aliases-tricks` toggle) is a separate command that prints history +with a timestamp prepended to every entry. + ## Sub-categories `__fish_config_op_aliases` sub-divides into six sub-categories, each with @@ -63,3 +67,48 @@ and the `help config` interception. `claude` (AGENTS.md/CLAUDE.md auto-linking) and `edit` (multi-editor launcher), plus `agy`. +## For function authors + +Calling one of these names bare from inside your own function means the +override runs whenever C1 (or its sub-category) is on — which may not be +what your function wants: a shadow can change stdout (`cat`'s syntax +highlighting, `mkdir`'s tree display), prompt interactively where none is +expected (`cp`/`mv`'s forced `-i`), or reshape output structurally (`ls`'s +icons/columns, `rg`'s hyperlink markers). If your function's logic depends +on stock behavior, bypass the shadow deterministically, regardless of the +toggle state: + + Shadow Bypass Why + ───────────────────────────────────────────────────────────────────────── + ls, cat, rm, less, du, command Real external + top, ping, ssh, rg, binaries — a + mkdir, bash, cp, mv, real system command + wget, grep/fgrep/egrep, exists to fall + dir/vdir, claude back to. + cd builtin cd The one true + fish builtin + in this table. + help config __original_help $argv `help` is neither + a builtin nor an + external binary + (embedded in the + fish binary + itself) — see + conf.d/help.fish + for why the + wrapper keeps its + own backup copy. + edit (nothing to bypass to) Purely our own + invention, no + stock command + exists. Call + $EDITOR/$VISUAL + yourself if you + want a plain + editor launch. + +A function's own doc header records which of these it depends on: see the +`CLASSIFICATION` label (`uses-shadow(...)` / `bypasses-shadow(...)`), +documented in full at +[`docs/function-classification-schema.md`](../../function-classification-schema.md). + diff --git a/docs/manualtools.py b/docs/manualtools.py index 70054a2..5a66d92 100644 --- a/docs/manualtools.py +++ b/docs/manualtools.py @@ -64,6 +64,7 @@ SECTIONS = ( "CATEGORY", "COMPONENT", "DEPENDENCIES", + "CLASSIFICATION", "SYNOPSIS", "DESCRIPTION", "ARGUMENTS", diff --git a/functions/__fish_config_sync_logging.fish b/functions/__fish_config_sync_logging.fish index f515a59..58e1708 100644 --- a/functions/__fish_config_sync_logging.fish +++ b/functions/__fish_config_sync_logging.fish @@ -4,6 +4,9 @@ # COMPONENT # logging/terminal-capture # +# CLASSIFICATION +# self-limiting(rm,mkdir) +# # SYNOPSIS # __fish_config_sync_logging # diff --git a/functions/__fish_help_header.fish b/functions/__fish_help_header.fish index 313ebad..c34f61b 100644 --- a/functions/__fish_help_header.fish +++ b/functions/__fish_help_header.fish @@ -1,6 +1,9 @@ # Copyright (C) 2026 Rootiest # SPDX-License-Identifier: AGPL-3.0-or-later +# CLASSIFICATION +# bypasses-shadow(cat) +# # SYNOPSIS # __fish_help_header [args...] # diff --git a/functions/__fish_real_command.fish b/functions/__fish_real_command.fish index d515c8b..9669ee2 100644 --- a/functions/__fish_real_command.fish +++ b/functions/__fish_real_command.fish @@ -1,6 +1,9 @@ # Copyright (C) 2026 Rootiest # SPDX-License-Identifier: AGPL-3.0-or-later +# CLASSIFICATION +# self-limiting(grep) +# # SYNOPSIS # __fish_real_command # diff --git a/functions/__fish_user_dots_link.fish b/functions/__fish_user_dots_link.fish index 1a5d46b..848fd87 100644 --- a/functions/__fish_user_dots_link.fish +++ b/functions/__fish_user_dots_link.fish @@ -4,6 +4,9 @@ # COMPONENT # autoexec/sync # +# CLASSIFICATION +# self-limiting(rm), destructive +# # SYNOPSIS # __fish_user_dots_link # diff --git a/functions/__kitty_logging_has_watcher.fish b/functions/__kitty_logging_has_watcher.fish index 3455eee..2cee31a 100644 --- a/functions/__kitty_logging_has_watcher.fish +++ b/functions/__kitty_logging_has_watcher.fish @@ -1,6 +1,9 @@ # Copyright (C) 2026 Rootiest # SPDX-License-Identifier: AGPL-3.0-or-later +# CLASSIFICATION +# bypasses-shadow(grep) +# # SYNOPSIS # __kitty_logging_has_watcher # diff --git a/functions/_agents_init_ensure_gitignore.fish b/functions/_agents_init_ensure_gitignore.fish index f1f5549..ca1d737 100644 --- a/functions/_agents_init_ensure_gitignore.fish +++ b/functions/_agents_init_ensure_gitignore.fish @@ -1,6 +1,9 @@ # Copyright (C) 2026 Rootiest # SPDX-License-Identifier: AGPL-3.0-or-later +# CLASSIFICATION +# self-limiting(grep) +# # SYNOPSIS # _agents_init_ensure_gitignore