diff --git a/completions/batten.bash b/completions/batten.bash index 3998021c3..3abd26ba6 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -292,6 +292,9 @@ _batten() { batten__subcmd__doctor,hooks) cmd="batten__subcmd__doctor__subcmd__hooks" ;; + batten__subcmd__doctor,mediator) + cmd="batten__subcmd__doctor__subcmd__mediator" + ;; batten__subcmd__doctor,session) cmd="batten__subcmd__doctor__subcmd__session" ;; @@ -301,6 +304,9 @@ _batten() { batten__subcmd__doctor__subcmd__help,hooks) cmd="batten__subcmd__doctor__subcmd__help__subcmd__hooks" ;; + batten__subcmd__doctor__subcmd__help,mediator) + cmd="batten__subcmd__doctor__subcmd__help__subcmd__mediator" + ;; batten__subcmd__doctor__subcmd__help,session) cmd="batten__subcmd__doctor__subcmd__help__subcmd__session" ;; @@ -511,6 +517,9 @@ _batten() { batten__subcmd__help__subcmd__doctor,hooks) cmd="batten__subcmd__help__subcmd__doctor__subcmd__hooks" ;; + batten__subcmd__help__subcmd__doctor,mediator) + cmd="batten__subcmd__help__subcmd__doctor__subcmd__mediator" + ;; batten__subcmd__help__subcmd__doctor,session) cmd="batten__subcmd__help__subcmd__doctor__subcmd__session" ;; @@ -2553,7 +2562,7 @@ _batten() { return 0 ;; batten__subcmd__doctor) - opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help hooks session help" + opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help mediator hooks session help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2583,7 +2592,7 @@ _batten() { return 0 ;; batten__subcmd__doctor__subcmd__help) - opts="hooks session help" + opts="mediator hooks session help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2624,6 +2633,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__doctor__subcmd__help__subcmd__mediator) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__doctor__subcmd__help__subcmd__session) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2668,6 +2691,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__doctor__subcmd__mediator) + opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__doctor__subcmd__session) opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -3471,7 +3524,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__doctor) - opts="hooks session" + opts="mediator hooks session" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3498,6 +3551,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__doctor__subcmd__mediator) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__doctor__subcmd__session) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index cad2e3903..b6a69155f 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -579,31 +579,54 @@ complete -c batten -n "__fish_batten_using_subcommand spec" -l no-color -d 'Neve complete -c batten -n "__fish_batten_using_subcommand spec" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand spec" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand spec" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' quiet\t'Suppress ordinary progress; keep warnings' normal\t'The default' verbose\t'Explain what is being checked' debug\t'Add resolution detail' trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -f -a "hooks" -d 'Diagnose whether batten is wired on every hook surface of every harness' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -f -a "session" -d 'Diagnose whether this session has declared work it has not finished' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -f -a "mediator" -d 'Diagnose whether the engine the registrations reach was built from this tree' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -f -a "hooks" -d 'Diagnose whether batten is wired on every hook surface of every harness' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -f -a "session" -d 'Diagnose whether this session has declared work it has not finished' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator hooks session help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from mediator" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from mediator" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from mediator" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from mediator" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from mediator" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from mediator" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from mediator" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from mediator" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from mediator" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from mediator" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from mediator" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from mediator" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from mediator" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from mediator" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from mediator" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from hooks" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -648,6 +671,7 @@ complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "mediator" -d 'Diagnose whether the engine the registrations reach was built from this tree' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "hooks" -d 'Diagnose whether batten is wired on every hook surface of every harness' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "session" -d 'Diagnose whether this session has declared work it has not finished' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' @@ -2919,6 +2943,7 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "deprecations" -d 'Report schema keys removed since a published release with no deprecation window' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "lint" -d 'Report policy smells in batten.toml (any smell is a violation)' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from lint" -f -a "brief" -d 'Check a delegation brief against the handoff schema (any missing section is a violation)' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from doctor" -f -a "mediator" -d 'Diagnose whether the engine the registrations reach was built from this tree' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from doctor" -f -a "hooks" -d 'Diagnose whether batten is wired on every hook surface of every harness' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from doctor" -f -a "session" -d 'Diagnose whether this session has declared work it has not finished' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from generate" -f -a "completions" -d 'Emit the shell completion script for one shell' diff --git a/completions/batten.zsh b/completions/batten.zsh index 24b4d48b8..d42fe7b17 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -937,7 +937,38 @@ trace\:"Add everything"))' \ (( CURRENT += 1 )) curcontext="${curcontext%:*:*}:batten-doctor-command-$line[1]:" case $line[1] in - (hooks) + (mediator) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'-J[Emit byte-stable JSON instead of pointer lines]' \ +'--json[Emit byte-stable JSON instead of pointer lines]' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +&& ret=0 +;; +(hooks) _arguments "${_arguments_options[@]}" : \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" standard\:"The default\: a finding is a violation" @@ -1011,7 +1042,11 @@ _arguments "${_arguments_options[@]}" : \ (( CURRENT += 1 )) curcontext="${curcontext%:*:*}:batten-doctor-help-command-$line[1]:" case $line[1] in - (hooks) + (mediator) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(hooks) _arguments "${_arguments_options[@]}" : \ && ret=0 ;; @@ -5013,7 +5048,11 @@ _arguments "${_arguments_options[@]}" : \ (( CURRENT += 1 )) curcontext="${curcontext%:*:*}:batten-help-doctor-command-$line[1]:" case $line[1] in - (hooks) + (mediator) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(hooks) _arguments "${_arguments_options[@]}" : \ && ret=0 ;; @@ -6107,6 +6146,7 @@ _batten__subcmd__design__subcmd__help__subcmd__help_commands() { (( $+functions[_batten__subcmd__doctor_commands] )) || _batten__subcmd__doctor_commands() { local commands; commands=( +'mediator:Diagnose whether the engine the registrations reach was built from this tree' \ 'hooks:Diagnose whether batten is wired on every hook surface of every harness' \ 'session:Diagnose whether this session has declared work it has not finished' \ 'help:Print this message or the help of the given subcommand(s)' \ @@ -6116,6 +6156,7 @@ _batten__subcmd__doctor_commands() { (( $+functions[_batten__subcmd__doctor__subcmd__help_commands] )) || _batten__subcmd__doctor__subcmd__help_commands() { local commands; commands=( +'mediator:Diagnose whether the engine the registrations reach was built from this tree' \ 'hooks:Diagnose whether batten is wired on every hook surface of every harness' \ 'session:Diagnose whether this session has declared work it has not finished' \ 'help:Print this message or the help of the given subcommand(s)' \ @@ -6132,6 +6173,11 @@ _batten__subcmd__doctor__subcmd__help__subcmd__hooks_commands() { local commands; commands=() _describe -t commands 'batten doctor help hooks commands' commands "$@" } +(( $+functions[_batten__subcmd__doctor__subcmd__help__subcmd__mediator_commands] )) || +_batten__subcmd__doctor__subcmd__help__subcmd__mediator_commands() { + local commands; commands=() + _describe -t commands 'batten doctor help mediator commands' commands "$@" +} (( $+functions[_batten__subcmd__doctor__subcmd__help__subcmd__session_commands] )) || _batten__subcmd__doctor__subcmd__help__subcmd__session_commands() { local commands; commands=() @@ -6142,6 +6188,11 @@ _batten__subcmd__doctor__subcmd__hooks_commands() { local commands; commands=() _describe -t commands 'batten doctor hooks commands' commands "$@" } +(( $+functions[_batten__subcmd__doctor__subcmd__mediator_commands] )) || +_batten__subcmd__doctor__subcmd__mediator_commands() { + local commands; commands=() + _describe -t commands 'batten doctor mediator commands' commands "$@" +} (( $+functions[_batten__subcmd__doctor__subcmd__session_commands] )) || _batten__subcmd__doctor__subcmd__session_commands() { local commands; commands=() @@ -6449,6 +6500,7 @@ _batten__subcmd__help__subcmd__design__subcmd__audit_commands() { (( $+functions[_batten__subcmd__help__subcmd__doctor_commands] )) || _batten__subcmd__help__subcmd__doctor_commands() { local commands; commands=( +'mediator:Diagnose whether the engine the registrations reach was built from this tree' \ 'hooks:Diagnose whether batten is wired on every hook surface of every harness' \ 'session:Diagnose whether this session has declared work it has not finished' \ ) @@ -6459,6 +6511,11 @@ _batten__subcmd__help__subcmd__doctor__subcmd__hooks_commands() { local commands; commands=() _describe -t commands 'batten help doctor hooks commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__doctor__subcmd__mediator_commands] )) || +_batten__subcmd__help__subcmd__doctor__subcmd__mediator_commands() { + local commands; commands=() + _describe -t commands 'batten help doctor mediator commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__doctor__subcmd__session_commands] )) || _batten__subcmd__help__subcmd__doctor__subcmd__session_commands() { local commands; commands=() diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index 165fb9459..4352d400d 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -1133,6 +1133,15 @@ pub enum DoctorCommand { /// Emit the per-harness diagnosis as byte-stable JSON. json: bool, }, + /// Whether the engine the registrations reach was built from this tree. + /// + /// A sub-verb rather than a check in the bare report, because whether an + /// install is current is a property of the world and bare `doctor` answers a + /// property of the commit — see [`crate::doctor::Mediator`]. + Mediator { + /// Emit the comparison as byte-stable JSON. + json: bool, + }, /// Whether this session has declared work it has not finished. Session { /// Emit the count and the open ids as byte-stable JSON. @@ -1462,6 +1471,9 @@ fn doctor_of(matches: &ArgMatches) -> DoctorCommand { Some(("hooks", matches)) => DoctorCommand::Hooks { json: flag(matches, "json"), }, + Some(("mediator", matches)) => DoctorCommand::Mediator { + json: flag(matches, "json"), + }, Some(("session", matches)) => DoctorCommand::Session { json: flag(matches, "json"), }, diff --git a/crates/batten/src/doctor.rs b/crates/batten/src/doctor.rs index bfba5b04b..f7cbdcea3 100644 --- a/crates/batten/src/doctor.rs +++ b/crates/batten/src/doctor.rs @@ -207,6 +207,143 @@ const PIN_RECORD: &str = "pin-record"; /// registration nor a sibling. const HOOK_HANDLERS: &str = "hook-handlers"; +/// Which engine the hook registrations actually reach (CLOUD-1349). +/// +/// **A stale mediator reads exactly like a working one**, which is the worst +/// shape in this model rather than an ordinary bug: silence from the hook is the +/// documented signal that it IS mediating, so an engine enforcing an old rule +/// table and one enforcing the committed table produce identical evidence. Every +/// other defence sits above where this fails — `input.tree.missing` as a channel, +/// `NotAcquired` keeping `Absent` and `Unparsed` apart, `RuleSkipped` reported +/// rather than folded into clean. Measured three times in one container on +/// 2026-09-02. +/// +/// **A SUB-VERB, NOT A CHECK IN THE BARE REPORT, AND THE PLACEMENT IS THE WHOLE +/// DECISION.** This landed once inside [`diagnose`] and was refused by `verify`: +/// `crates/batten/tests/it/doctor.rs::this_repository_is_healthy` went red +/// because `land` had rebuilt `target/release/batten` while the installed copy +/// was an hour old. The check was telling the truth. Whether a container's +/// install is current is a property of the WORLD, and bare `doctor` answers a +/// property of the COMMIT — so folding it in made a commit gate answer on install +/// recency. `.claude/rules/toolchain.md` records that exact defect for +/// `lock-check`, whose remedy was the same split: the pure gate keeps its +/// question, the world-fact gets its own caller. House style §2 already specifies +/// `doctor ` for a focused diagnostic, so the shape was available. +/// +/// **Content, never the version string, correcting this row's own §2.** Measured: +/// `batten --version` read `0.0.137`, the workspace read `0.0.137`, and that +/// binary refused the tree's own `batten.toml` with `unknown field +/// endpoint_contains`. A config surface moves without a version bump, and on a +/// fast-forward-only trunk that is the ordinary case, so version equality does +/// not discriminate. The digest catches that and every case a version would. +/// +/// **Nothing is executed.** `doctor` is `Effect::Read` and the agent allowlist is +/// `filter(effect == read)` with no second list, so spawning a program named by a +/// wiring file would put config-supplied code behind a row any consumer's agent +/// may call — CLOUD-170's actual invariant, and the reason [`on_path`] stats +/// rather than runs. Hashing a file reaches none of it. +/// +/// # What this does NOT catch, measured rather than reasoned +/// +/// **It compares the INSTALL against the BUILD, not the build against the +/// SOURCE.** When `target/release/batten` is itself behind the tree, both sides +/// of the comparison are equally stale, they agree, and this reports +/// [`Mediator::Current`]. +/// +/// Measured 2026-09-02, one command apart and while this very row was in flight: +/// `land` rebased onto a `main` that had added a `[[rule.review]]` key, the +/// engine refused the tree's own `batten.toml` with `unknown field`, and +/// `batten doctor mediator` answered `mediator ok`. Both binaries were the same +/// pre-rebase build. That is the fourth staleness occurrence in one container +/// that day and the first this check missed. +/// +/// **Stated here rather than left for a reader to discover**, because a check +/// that silently answers a narrower question than its name suggests is the very +/// shape this file is against: a dead gate and a clean tree are byte-identical on +/// the decision surface. What is bought is the ORIGINAL measured failure — an +/// image-baked or hand-installed binary against a tree that builds a different +/// one — which is the case that ran unnoticed for six hours. +/// +/// Closing the remainder needs a predicate over *build freshness* that is still a +/// read: comparing a binary's mtime against its sources is the obvious candidate +/// and is not obviously sound, since a rebase touches files cargo would not +/// rebuild from, so it trades this false negative for a false positive. That is a +/// separate predicate with its own design, not a tightening of this one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case", tag = "state")] +pub enum Mediator { + /// The resolved binary is byte-identical to this tree's build. + Current, + /// Both were read and they differ. + Stale, + /// This tree does not build a mediator, so there is nothing to compare. + /// + /// Distinct from the two below: a consumer checkout is not a failed lookup, + /// it is a question with no referent. + NotApplicable, + /// This tree builds one, but nothing named `batten` resolves on `PATH`. + Unresolvable, + /// This tree builds one and the built artifact could not be read. + Unbuilt, +} + +impl Mediator { + /// The pointer line this renders as, without a trailing newline. + #[must_use] + pub const fn line(&self) -> &'static str { + match self { + Mediator::Current => "mediator ok", + Mediator::Stale => "mediator failed mediator-stale", + Mediator::NotApplicable => "mediator ok not-applicable", + Mediator::Unresolvable => "mediator failed mediator-unresolvable", + Mediator::Unbuilt => "mediator failed mediator-unbuilt", + } + } + + /// The exit code this maps to. + /// + /// [`ExitCode::Violation`] is unreachable, inheriting the promise the parent + /// makes: a mediating harness reads `2` as a deny, and "your install is out + /// of date" is not "policy says no". + #[must_use] + pub const fn code(&self) -> ExitCode { + match self { + Mediator::Current | Mediator::NotApplicable => ExitCode::Success, + Mediator::Stale | Mediator::Unresolvable | Mediator::Unbuilt => ExitCode::Usage, + } + } +} + +/// Compare the mediator on `PATH` against the artifact `dir` builds. +/// +/// Reads both files and hashes them; spawns nothing. Length is compared first +/// only as a short-circuit — two files of different lengths cannot be identical. +#[must_use] +pub fn diagnose_mediator(dir: &Path) -> Mediator { + // The tree builds a mediator iff it carries the crate that produces one. + // Asking the manifest rather than looking for the artifact keeps "a consumer + // checkout" and "batten's own checkout before its first build" distinct: the + // second is a could-not-look and the first is not a question at all. + if !dir.join("crates/batten/Cargo.toml").is_file() { + return Mediator::NotApplicable; + } + let Some(resolved) = crate::rules::on_path_verbatim("batten") else { + return Mediator::Unresolvable; + }; + let built = dir.join("target/release/batten"); + let (Ok(left), Ok(right)) = (std::fs::read(&resolved), std::fs::read(&built)) else { + return Mediator::Unbuilt; + }; + if left.len() != right.len() { + return Mediator::Stale; + } + if crate::receipt::hex_sha256(&left) == crate::receipt::hex_sha256(&right) { + Mediator::Current + } else { + Mediator::Stale + } +} + /// Whether `program` resolves to an existing file on `PATH`. /// /// **Stats, never executes.** Running the program to see whether it exists is diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index ea6212de9..5e98e507d 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -11741,12 +11741,30 @@ fn engine_side_findings(root: &Path, config: &resolve::Resolved) -> Result Result> { + match config.defects.as_ref() { + Some(declared) => defects::gate(root, declared), + None => Ok(Vec::new()), + } +} + /// The two stderr notices every rule-running verb opens with. /// /// Lifted out of [`run_rules`] to keep that funnel under the line lint, and the @@ -11899,12 +11917,32 @@ fn run_rules( // being an ordinary `Finding`, and all of which a private verdict path would // have had to re-implement. An over-budget set was previously visible only // to whoever thought to run `policy budget`, which is a report, not a gate. - // The engine-side gates are skipped entirely under a narrowing: a caller - // asking about one declared row is not asking about the budget or the + // The engine-side gates are skipped under a narrowing ON THE READ SURFACE: a + // caller asking about one declared row is not asking about the budget or the // ledger, and running them would make a narrowed read fail for a reason it // did not ask about. + // + // THE SPAWNING SURFACE DOES NOT GET THAT SKIP, AND THE ASYMMETRY IS THE WHOLE + // POINT (CLOUD-1186). The ledger gate lives engine-side precisely so a branch + // cannot lower it by editing a rule table — and a narrowing that dropped it + // here would be a one-token way to do exactly that, on the verb that runs + // user-declared commands. A convenience on `check` is a hole on `enforce`. + // + // MEASURED, AND SHIPPED BROKEN FOR ONE DAY: CLOUD-1358 gave `enforce` a + // `--rule` selector while this branch still read `only.is_empty()` alone, so + // between that merge and this commit `batten enforce --rule ` skipped the + // ledger. CLOUD-1186 had predicted that exact regression, in those words, + // before the selector landed. + // + // The BUDGET keeps the skip on both surfaces: it is a measurement over + // declared instruction files rather than a claim about this branch's + // conduct, so a narrowed run failing on it is the "reason it did not ask + // about" this comment already refuses. The ledger is the security property; + // the budget is not. if only.is_empty() { findings.extend(engine_side_findings(&root, &config)?); + } else if surface == Surface::Spawning { + findings.extend(ledger_findings(&root, &config)?); } // The transcript capability (CLOUD-95), resolved BESIDE the runner rather than @@ -12589,10 +12627,34 @@ fn run_doctor(command: &cli::DoctorCommand, out: &mut dyn Write) -> Result run_diagnose(json, out), cli::DoctorCommand::Hooks { json } => run_doctor_hooks(json, out), + cli::DoctorCommand::Mediator { json } => run_doctor_mediator(json, out), cli::DoctorCommand::Session { json } => run_doctor_session(json, out), } } +/// Was the engine the registrations reach built from this tree (CLOUD-1349)? +/// +/// `doctor hooks` answers whether the registrations reach an engine; this answers +/// WHICH one, and the gap between those two questions is where a session spends +/// six hours believing it is mediated. Rationale, and why it is a sub-verb rather +/// than a fourth check in the bare report, on [`doctor::Mediator`]. +/// +/// One pointer line, never a digest: a hash is stable per content but varies per +/// machine, so emitting one would defeat the byte-stability §6 requires of this +/// verb's output while telling the reader nothing they can act on. The remedy is +/// `mise run install:local` and the verdict is what says whether to run it. +fn run_doctor_mediator(json: bool, out: &mut dyn Write) -> Result { + let report = doctor::diagnose_mediator(Path::new(".")); + if json { + // A data channel emits its document unconditionally, including when the + // mediator is current: JSON that is sometimes absent is unparseable. + writeln!(out, "{}", serde_json::to_string_pretty(&report)?)?; + } else { + writeln!(out, "{}", report.line())?; + } + Ok(report.code()) +} + fn run_diagnose(json: bool, out: &mut dyn Write) -> Result { let report = doctor::diagnose(Path::new(".")); if json { diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index 56d32e89b..17f113d48 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -451,6 +451,15 @@ mod tests { // spawn nothing: the sub-verb compares each harness's wiring // against a derivation computed in-process. "doctor hooks".to_owned(), + // WHICH engine the registrations reach, where the row above + // answers whether they reach one at all (CLOUD-1349). Read for a + // stricter reason than its sibling: it resolves a program name + // and then deliberately does NOT run it, comparing the file's + // bytes against the artifact this tree builds. Spawning what a + // wiring file names would put config-supplied code behind a row + // on this very allowlist, which is CLOUD-170's invariant and the + // reason `on_path` stats rather than executes. + "doctor mediator".to_owned(), // `read`, and structurally, for the sub-verb above's reason: it // opens the session's own task store through a link the engine // parked and counts what is not `completed`. It spawns nothing, @@ -685,6 +694,7 @@ mod tests { "design audit".to_owned(), "doctor".to_owned(), "doctor hooks".to_owned(), + "doctor mediator".to_owned(), "doctor session".to_owned(), "enforce".to_owned(), "exec".to_owned(), diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 22b0f9111..4f1c21c53 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -2347,6 +2347,30 @@ pub const SURFACE: &[CommandDecl] = &[ // `read`, and structurally: it reads committed wiring files and compares them // against a derivation computed in-process. Nothing is spawned, and §2's own // row already classifies the verb this way. + // WHICH engine the registrations reach, where `doctor hooks` answers whether + // they reach one at all (CLOUD-1349). It reported `5 harness(es), 0 unwired` + // over a binary 16 versions behind the tree it was adjudicating. + // + // A SUB-VERB BECAUSE THE SUBJECT IS THE WORLD, NOT THE CHECKOUT. This landed + // once as a fourth check inside `diagnose()` and `verify` refused it: bare + // `doctor` is asserted green over this repository by a compiled-binary case, + // and an install that has not caught up with a rebuild made that case a + // function of install recency. `.claude/rules/toolchain.md` states the rule + // from `lock-check`'s post-mortem — a property of the commit belongs in the + // gate, a property of the world belongs to its own caller — and §2's + // `doctor ` is the shape that was already specified for it. + // + // `read`, and structurally: it reads two files and hashes them. Nothing is + // spawned, which is what keeps it off the wrong side of CLOUD-170 while + // sitting on the `filter(effect == read)` allowlist. + CommandDecl { + path: "doctor mediator", + id: "doctor.mediator", + about: "Diagnose whether the engine the registrations reach was built from this tree", + data_channel: true, + effect: Effect::Read, + flags: &[JSON], + }, CommandDecl { path: "doctor hooks", id: "doctor.hooks", diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index f6a6ba947..ffe1851ba 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -6120,8 +6120,17 @@ fn the_committed_repo_config_gates_a_repository() { // This spawns `hk`, so it needs hk on PATH — true under `mise run // test:cargo` and false under a bare `cargo test`, where it fails loudly // with "cannot run `hk`: not found on PATH" rather than passing silently. + // + // NARROWED TO THE ONE ROW IT ASSERTS (CLOUD-1358's mechanism, this case's + // turn). The comment above already states that the asserted stdout is + // byte-identical whichever of the other ~100 rows are present — so running + // them bought nothing and cost 436.42s on the 2-core Windows runner, 24% of + // that suite's entire CPU and its single largest item, against 21.5s here. + // The ratio is the contention signature, not a different workload. let output = batten() .arg("enforce") + .arg("--rule") + .arg("no-conflict-markers") .current_dir(&dir) .state_home(&home) .env_remove("BATTEN_STRICTNESS") diff --git a/crates/batten/tests/it/defects.rs b/crates/batten/tests/it/defects.rs index 567e6aac1..8d2f10e1c 100644 --- a/crates/batten/tests/it/defects.rs +++ b/crates/batten/tests/it/defects.rs @@ -50,6 +50,116 @@ fn ledger_text(dir: &Path) -> String { std::fs::read_to_string(dir.join("defects.jsonl")).unwrap_or_default() } +// --- a narrowing must not lower the gate (CLOUD-1186) ------------------------ + +/// [`CONFIG`] plus one narrowable row that cannot fire on these fixtures. +/// +/// A row that fired would make the cases below unable to tell the ledger's +/// verdict from the rule's, which is the whole thing they discriminate. The glob +/// names a path the fixtures never create. +const CONFIG_WITH_ROW: &str = "version = 1\n\n[defects]\npath = \"defects.jsonl\"\nclasses = [\"false-green\", \"silent-skip\"]\n\n[[rule]]\nid = \"never-fires\"\nkind = \"forbid\"\nglob = \"no-such-dir/**\"\npattern = \"zzz-absent\"\nseverity = \"deny\"\nscope = \"tree\"\n"; + +/// A repo carrying a ledger that was REWRITTEN — the violation the gate exists +/// for — plus a narrowable row. +fn tampered_repo(name: &str) -> PathBuf { + let base = format!( + "{}\n{}\n", + row("d-1", "false-green", "a.rs:1"), + row("d-2", "silent-skip", "b.rs:2") + ); + let dir = Fixture::new(name) + .config(CONFIG_WITH_ROW) + .file("defects.jsonl", &base) + .git() + .build(); + git_in(&dir, &["add", "-A"]); + git_in(&dir, &["commit", "-q", "-m", "base"]); + common::write( + &dir, + "defects.jsonl", + &format!( + "{}\n{}\n", + row("d-1", "false-green", "a.rs:1"), + row("d-2", "silent-skip", "SOMEWHERE-ELSE:9") + ), + ); + dir +} + +#[test] +fn a_narrowed_enforce_still_evaluates_the_defect_ledger() { + // THE SECURITY PROPERTY, and the one that would silently regress. The ledger + // gate is engine-side rather than a `[[rule]]` row precisely so a branch + // cannot lower it by editing a rule table — so a narrowing that dropped it on + // the spawning verb would restore that lowering in one token. + // + // MEASURED AS A REAL REGRESSION, not a hypothetical: CLOUD-1358 gave + // `enforce` a `--rule` selector while the skip still keyed on the narrowing + // alone, and `batten enforce --rule ` skipped the ledger on `main` for a + // day. CLOUD-1186 had predicted it in those words before the selector landed. + let dir = tampered_repo("defects-narrowed-enforce"); + let output = run(&dir, &["enforce", "--rule", "never-fires"]); + assert_eq!( + output.status.code(), + Some(2), + "a narrowed enforce still answers for the ledger: {}", + stdout(&output) + ); + assert!( + stdout(&output).contains("defect-not-append-only"), + "and it is the ledger's own finding: {}", + stdout(&output) + ); +} + +#[test] +fn a_narrowed_check_still_skips_the_defect_ledger() { + // THE OTHER HALF, unchanged and deliberately so. On the read surface the skip + // is the convenience it was: a caller asking about one row is not asking + // about the ledger, and failing there would be a verdict they did not + // request. Asserted rather than assumed, because "fix the hole" applied + // symmetrically would have taken this with it. + let dir = tampered_repo("defects-narrowed-check"); + let output = run(&dir, &["check", "--rule", "never-fires"]); + assert_eq!( + output.status.code(), + Some(0), + "a narrowed read is silent about the ledger: {}", + stdout(&output) + ); +} + +#[test] +fn an_unnarrowed_check_still_answers_for_the_ledger() { + // THE ANTI-VACUITY MIRROR for the case above: if this fixture's ledger were + // clean, or the gate were off entirely, the narrowed-check case would pass + // for the wrong reason and prove nothing. Same repo, no narrowing. + let dir = tampered_repo("defects-unnarrowed-check"); + let output = run(&dir, &["check"]); + assert_eq!( + output.status.code(), + Some(2), + "the tampering IS visible to an unnarrowed run: {}", + stdout(&output) + ); + assert!(stdout(&output).contains("defect-not-append-only")); +} + +#[test] +fn a_narrowed_enforce_naming_no_declared_row_is_a_usage_error() { + // The anti-vacuous-pass guarantee carries to the spawning surface: a typo + // must not read as "the gate passed", which is the same reasoning the ledger + // skip above is about, one layer up. + let dir = tampered_repo("defects-narrowed-unknown"); + let output = run(&dir, &["enforce", "--rule", "no-such-row"]); + assert_eq!( + output.status.code(), + Some(1), + "an unmatched id is a usage error, never a clean run: {}", + stdout(&output) + ); +} + // --- the gate ------------------------------------------------------------ #[test] diff --git a/crates/batten/tests/it/doctor.rs b/crates/batten/tests/it/doctor.rs index 572886b4a..58d94dcc8 100644 --- a/crates/batten/tests/it/doctor.rs +++ b/crates/batten/tests/it/doctor.rs @@ -54,6 +54,206 @@ fn doctor(dir: &Path, extra: &[&str]) -> Output { .expect("run batten doctor") } +// --- doctor mediator: WHICH engine the registrations reach (CLOUD-1349) ------- + +/// A checkout that builds a mediator, with `batten` on `PATH` resolving to +/// `planted`. +/// +/// The whole fixture is two files plus a `PATH` reaching one of them: the +/// comparison is over CONTENT, so a case only has to control what those files +/// hold. Nothing is executed, so neither needs to be a real program — which is +/// also what keeps these cases fast and portable. +fn mediator_fixture(name: &str, planted: &[u8], built: &[u8]) -> PathBuf { + let dir = scratch(name, true, Some("version = 1\n")); + // The manifest is what says "this tree builds a mediator". Its contents are + // never parsed — only its existence decides the question is askable — so the + // marker is deliberately minimal. + fs::create_dir_all(dir.join("crates/batten")).unwrap(); + fs::write(dir.join("crates/batten/Cargo.toml"), "# marker\n").unwrap(); + fs::create_dir_all(dir.join("target/release")).unwrap(); + fs::write(dir.join("target/release/batten"), built).unwrap(); + let bin = dir.join("planted-bin"); + fs::create_dir_all(&bin).unwrap(); + fs::write(bin.join("batten"), planted).unwrap(); + dir +} + +fn mediator(dir: &Path, bin: Option<&Path>, extra: &[&str]) -> Output { + let mut command = batten(); + command.arg("doctor").arg("mediator"); + command.args(extra); + if let Some(bin) = bin { + command.env("PATH", bin); + } + command + .current_dir(dir) + .env_remove("BATTEN_STRICTNESS") + .env_remove("BATTEN_FAIL_ON_WARNING") + .env_remove("BATTEN_CONFIG_FROM") + .output() + .expect("run batten doctor mediator") +} + +#[test] +fn a_mediator_that_is_not_this_trees_build_is_refused() { + // The measured failure reduced to its decidable core: the binary answering + // calls is not the one this source produces. No version appears in the + // fixture at all, because the version is exactly what could NOT tell these + // two apart in the field — both sides read 0.0.137 while one of them refused + // the tree's own config. + let dir = mediator_fixture("mediator-stale", b"an older build", b"this tree's build"); + let output = mediator(&dir, Some(&dir.join("planted-bin")), &[]); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(stdout(&output), "mediator failed mediator-stale\n"); +} + +#[test] +fn a_mediator_built_from_this_tree_passes() { + // THE ANTI-VACUITY MIRROR, and it is what makes the case above mean + // anything: a check that refused unconditionally would satisfy that one + // exactly as well. Same fixture shape, same PATH, identical bytes. + let dir = mediator_fixture("mediator-current", b"same bytes", b"same bytes"); + let output = mediator(&dir, Some(&dir.join("planted-bin")), &[]); + assert_eq!(output.status.code(), Some(0)); + assert_eq!(stdout(&output), "mediator ok\n"); +} + +#[test] +fn equal_length_binaries_that_differ_are_still_refused() { + // The length compare is a short-circuit, never the predicate. Two builds of + // the same source at the same length is the ordinary case for a change to a + // constant, so a check that stopped at the length would pass over precisely + // the drift hardest to notice by eye. + let dir = mediator_fixture("mediator-same-length", b"aaaaaaaaaa", b"bbbbbbbbbb"); + let output = mediator(&dir, Some(&dir.join("planted-bin")), &[]); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(stdout(&output), "mediator failed mediator-stale\n"); +} + +#[test] +fn a_tree_that_builds_no_mediator_abstains_rather_than_refusing() { + // A consumer checkout never builds one, so "was this built from this tree" + // has no referent there. Abstaining is the honest answer; refusing would + // redden every consumer over a question that does not apply to them — and it + // is reported as `not-applicable` rather than as a bare ok, so abstention is + // legible rather than indistinguishable from a real comparison. + let dir = scratch("mediator-not-applicable", true, Some("version = 1\n")); + let output = mediator(&dir, None, &[]); + assert_eq!(output.status.code(), Some(0)); + assert_eq!(stdout(&output), "mediator ok not-applicable\n"); +} + +#[test] +fn a_tree_that_builds_one_but_has_not_is_could_not_look_never_clean() { + // The distinction the two named variants exist to keep: this tree SHOULD + // have an artifact to compare and does not, which is a different claim from + // a consumer checkout that never had one. Reading it as clean is the exact + // shape — an unanswerable question passing — this verb is against. + let dir = scratch("mediator-unbuilt", true, Some("version = 1\n")); + fs::create_dir_all(dir.join("crates/batten")).unwrap(); + fs::write(dir.join("crates/batten/Cargo.toml"), "# marker\n").unwrap(); + let bin = dir.join("planted-bin"); + fs::create_dir_all(&bin).unwrap(); + fs::write(bin.join("batten"), b"whatever").unwrap(); + let output = mediator(&dir, Some(&bin), &[]); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(stdout(&output), "mediator failed mediator-unbuilt\n"); +} + +#[test] +fn a_tree_that_builds_one_with_no_mediator_on_path_is_could_not_look() { + // Distinct from unbuilt for the same reason `NotAcquired` keeps `Absent` and + // `Unparsed` apart: nothing to compare AGAINST and nothing to compare WITH + // have different remedies, so one reason id for both sends the reader to the + // wrong place. + let dir = mediator_fixture("mediator-unresolvable", b"planted", b"built"); + let empty = dir.join("empty-bin"); + fs::create_dir_all(&empty).unwrap(); + let output = mediator(&dir, Some(&empty), &[]); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(stdout(&output), "mediator failed mediator-unresolvable\n"); +} + +#[test] +fn two_equally_stale_binaries_agree_and_this_reports_current() { + // THE BOUND, ASSERTED RATHER THAN ONLY DESCRIBED. The comparison is + // install-against-build, so when the BUILD is itself behind the source both + // sides agree and the verdict is `ok`. Measured 2026-09-02 while this row was + // in flight: `land` rebased onto a `main` carrying a new `[[rule.review]]` + // key, the engine refused the tree's own batten.toml, and this verb answered + // `mediator ok` one command later. + // + // Pinned as a case because a bound stated only in prose is one a later change + // can quietly widen or narrow with nothing going red. If a build-freshness + // predicate ever lands, this case is what must be revisited — deliberately, + // and not by discovering the comment was already false. + let dir = mediator_fixture("mediator-both-stale", b"old build", b"old build"); + // The source moving is what the pair cannot see: the fixture's own manifest + // is rewritten after both binaries were planted, and nothing in the verdict + // changes. + fs::write(dir.join("crates/batten/Cargo.toml"), "# moved on\n").unwrap(); + let output = mediator(&dir, Some(&dir.join("planted-bin")), &[]); + assert_eq!(output.status.code(), Some(0)); + assert_eq!(stdout(&output), "mediator ok\n"); +} + +#[test] +fn the_verdict_carries_no_path_and_no_digest() { + // §6 and rule 4 over this verb specifically: it is about two absolute paths + // and two hashes, which is the shape most likely to leak one into output. + // A digest is stable per content but varies per machine, so emitting one + // would defeat byte-stability while telling the reader nothing actionable. + let dir = mediator_fixture("mediator-no-path", b"older", b"newer"); + let text = stdout(&mediator(&dir, Some(&dir.join("planted-bin")), &[])); + assert!(!text.contains('/'), "the verdict carried a path: {text}"); + assert!( + !text + .chars() + .any(|c| c.is_ascii_hexdigit() && !c.is_ascii_alphabetic()), + "the verdict carried a digest: {text}" + ); +} + +#[test] +fn the_data_channel_emits_a_document_even_when_current() { + // A data channel emits unconditionally: JSON that is sometimes absent is + // unparseable. Asserted on the passing arm because that is the one a caller + // is tempted to make silent. + let dir = mediator_fixture("mediator-json", b"same", b"same"); + let output = mediator(&dir, Some(&dir.join("planted-bin")), &["--json"]); + assert_eq!(output.status.code(), Some(0)); + let report: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("the verdict is JSON"); + assert_eq!(report["state"], "current"); +} + +#[test] +fn the_sub_verb_never_renders_a_policy_verdict() { + // A sub-verb inherits the promise the parent makes: a mediating harness + // reads `2` as a deny, and "your install is out of date" is not "policy says + // no". Every failing arm above asserts 1; this pins that 2 is unreachable. + let dir = mediator_fixture("mediator-never-two", b"older", b"newer"); + let output = mediator(&dir, Some(&dir.join("planted-bin")), &[]); + assert_ne!(output.status.code(), Some(2)); +} + +#[test] +fn the_bare_report_is_unchanged_by_this_sub_verb() { + // THE REGRESSION THIS VERB EXISTS AS A SUB-VERB TO AVOID. An earlier revision + // put the comparison in `diagnose()`'s check list, and `this_repository_is_healthy` + // went red whenever a rebuild had outpaced the install — a world-property + // deciding a commit gate (`.claude/rules/toolchain.md`, from `lock-check`). + // Bare `doctor` must not mention the mediator at all. + let dir = mediator_fixture("mediator-bare-unchanged", b"older", b"newer"); + let output = doctor(&dir, &[]); + let text = stdout(&output); + assert!( + !text.contains("mediator"), + "the bare report grew it: {text}" + ); + assert_eq!(output.status.code(), Some(0)); +} + // --- the diagnosis ----------------------------------------------------------- #[test] diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index 1caa6d063..08da2dfa8 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -1008,6 +1008,19 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::Nothing, disposition: Disposition::PointerOnly, }, + // Pointer-only for a sharper reason than its siblings, because this verb's + // whole subject is two absolute paths and two digests (CLOUD-1349) — the + // shape most likely to leak one into output. It emits neither: the verdict is + // a stable token, and a digest is deliberately withheld because it is stable + // per content but varies per machine, so printing one would defeat §6's + // byte-stability while telling the reader nothing they can act on. The remedy + // is `mise run install:local`, and the verdict is what says whether to run it. + Verb { + path: "doctor mediator", + args: &[], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, // THE SESSION'S OWN DECLARED WORK (CLOUD-1376), and its content class is the // reason it belongs here rather than being obvious. What this verb reads is a // task store whose members carry a `subject` and a `description` — free prose diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index 5e76e9195..60a16c345 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -853,6 +853,24 @@ expression: stdout_of(&output) ], "subcommands": [] }, + { + "path": "doctor mediator", + "id": "doctor.mediator", + "about": "Diagnose whether the engine the registrations reach was built from this tree", + "effect": "read", + "data_channel": true, + "flags": [ + { + "name": "json", + "short": "J", + "long": "json", + "takes_value": false, + "positional": false, + "help": "Emit byte-stable JSON instead of pointer lines" + } + ], + "subcommands": [] + }, { "path": "doctor session", "id": "doctor.session", @@ -2440,6 +2458,10 @@ expression: stdout_of(&output) "id": "doctor.hooks", "path": "doctor hooks" }, + { + "id": "doctor.mediator", + "path": "doctor mediator" + }, { "id": "doctor.session", "path": "doctor session" diff --git a/crates/batten/tests/it/stop_posture.rs b/crates/batten/tests/it/stop_posture.rs index 80202a116..03fa10545 100644 --- a/crates/batten/tests/it/stop_posture.rs +++ b/crates/batten/tests/it/stop_posture.rs @@ -185,38 +185,48 @@ fn stop_payload(message: &str, active: bool) -> String { .to_string() } +/// `batten hook` against the fixture's own state home. +/// +/// **ISOLATED FOR EVERY CASE, not only the one that asserts silence.** This +/// delegates to [`hook_in`] rather than carrying its own ambient invocation, and +/// the reason is a defect measured twice on 2026-09-03. +/// +/// The first reading looked like it touched one case: `a_turn_that_strands_ +/// nothing_is_silent` went red inside a `land` lap and green on the next isolated +/// run of the same commit, because the real session's `unlanded` finding reached +/// the fixture. The explanation offered then was that only a silence-asserting +/// case is exposed, since an EXTRA advisory cannot falsify a case asserting a +/// specific one. +/// +/// **That was wrong, and the next lap proved it**: +/// `a_stranded_finding_is_pointed_at_and_the_turn_still_ends` failed the same +/// way. The engine emits **at most one** nudge, ranked — two on a turn is how a +/// channel stops being read — so an ambient finding does not add to the expected +/// advisory, it DISPLACES it. Every case here is exposed, not just the silent +/// one. +/// +/// `unlanded` reports once per HEAD sha and every lap rebases to a fresh one, so +/// the exposure is worst exactly while its author is landing: red on the lap, +/// green on the re-run, which is the shape that gets called a flake and re-run +/// until it passes. fn hook(dir: &Path, payload: &str) -> Output { - let mut command = batten(); - // THE STATE HOME IS CONTAINED, for the reason the unlanded fixture's own - // runner already states: the Stop tier reads the out-of-tree findings store, - // and an ambient one lets a REAL session's findings decide a fixture's - // verdict. Measured 2026-09-03 — `a_clean_final_message_says_nothing` failed - // with `unlanded: 1 commit(s) not on the landing target`, read from the - // checkout the suite was running in. It passes on a tree with nothing - // unlanded and fails on any branch mid-development, which is every branch - // this suite is ever run from. - let home = scratch(&format!( - "{}-home", - dir.file_name() - .and_then(|name| name.to_str()) - .unwrap_or("stop-posture") - )); - common::state_home(&mut command, &home); - command - .current_dir(dir) - .args(["hook", "--harness", "claude-code"]) - .env_remove("BATTEN_HOOK_BYPASS") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - let mut child = command.spawn().expect("spawn batten hook"); - child - .stdin - .take() - .expect("piped stdin") - .write_all(payload.as_bytes()) - .expect("write payload"); - child.wait_with_output().expect("run batten hook") + // Delegates rather than repeating the containment inline, and that is the + // resolution of a genuine collision: `main` fixed this same defect in + // parallel by setting the state home here directly. Both isolate the + // findings store; `hook_in` additionally sets `GIT_CEILING_DIRECTORIES`, so + // discovery cannot climb out of the fixture either — and a second inline + // copy of the containment is the very shape that caused this bug, where + // `hook` and `hook_in` disagreed about what a fixture owns. + // + // The parallel fix's measurement, kept because it is a different case than + // the two below: `a_clean_final_message_says_nothing` failed with + // `unlanded: 1 commit(s) not on the landing target`, read from the checkout + // the suite was running in. It passes on a tree with nothing unlanded and + // fails on any branch mid-development, which is every branch this suite is + // ever run from. + let home = dir.join("hook-home"); + fs::create_dir_all(&home).expect("home dir"); + hook_in(dir, &home, payload) } /// A repository whose branch carries a commit the landing target lacks, plus a @@ -603,7 +613,25 @@ fn the_measured_rule_keeps_precedence_when_both_would_fire() { fn a_turn_that_strands_nothing_is_silent() { let dir = repo("stop-finding-sink-clean"); stub(&dir, "mise-tasks/finding-sink-check.sh", 0, ""); - let stdout = stdout_of(&hook(&dir, &stop_with_transcript(&dir, "Landed."))); + // ISOLATED, because this is the one case here that asserts SILENCE and so is + // the one a real session's findings can decide. `hook_in`'s own comment + // states the hazard — "an ambient one would let a real session's findings + // decide a fixture's verdict" — and this case was reaching the ambient store + // anyway. + // + // Measured 2026-09-03: it failed inside a `land` lap and passed on the next + // isolated run of the same commit. Not a flake. `unlanded` reports once per + // HEAD sha, and every lap rebases to a fresh one, so the real session's + // unlanded work was unreported at exactly the moment the lap ran the suite + // and reported at every other moment. A case that goes red only while its + // author is landing is red for the author and green for everyone else. + let home = dir.join("home"); + fs::create_dir_all(&home).expect("home dir"); + let stdout = stdout_of(&hook_in( + &dir, + &home, + &stop_with_transcript(&dir, "Landed."), + )); assert!( !stdout.contains("additionalContext"), "silence is the default: {stdout}" diff --git a/man/batten-doctor-mediator.1 b/man/batten-doctor-mediator.1 new file mode 100644 index 000000000..f35db61ec --- /dev/null +++ b/man/batten-doctor-mediator.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-doctor-mediator 1 batten +.SH NAME +batten\-doctor\-mediator \- Diagnose whether the engine the registrations reach was built from this tree +.SH SYNOPSIS +\fBbatten doctor mediator\fR [\fB\-J\fR|\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Diagnose whether the engine the registrations reach was built from this tree +.SH OPTIONS +.TP +\fB\-J\fR, \fB\-\-json\fR +Emit byte\-stable JSON instead of pointer lines +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-doctor.1 b/man/batten-doctor.1 index 8d4302e8d..7d748445c 100644 --- a/man/batten-doctor.1 +++ b/man/batten-doctor.1 @@ -16,6 +16,9 @@ Emit byte\-stable JSON instead of pointer lines Print help .SH SUBCOMMANDS .TP +batten\-doctor\-mediator(1) +Diagnose whether the engine the registrations reach was built from this tree +.TP batten\-doctor\-hooks(1) Diagnose whether batten is wired on every hook surface of every harness .TP