diff --git a/.mise/config.dotnet.toml b/.mise/config.dotnet.toml index 08dc9329..adbc6847 100644 --- a/.mise/config.dotnet.toml +++ b/.mise/config.dotnet.toml @@ -7,11 +7,9 @@ dotnet = "10.0.300" [vars] # MSBuild dir inside the mise dotnet SDK, for the roslynator `-m` flags below. -# `$$DOTNET_ROOT` escapes mise's config-load `$VAR` expansion (`$$` -> literal `$`), so the value stays -# `$DOTNET_ROOT/...` for the task shell to expand at run time. DOTNET_ROOT is a tool-plugin env var that mise -# only injects once the dotnet tool is active; at config-parse time (e.g. mid `mise install`) it isn't set, so -# a bare `$DOTNET_ROOT` would emit a spurious "not defined" warning. The version segment is the [tools] dotnet -# pin above -- bump together. +# DOTNET_ROOT is left for the task shell rather than read with `{{ env.DOTNET_ROOT }}`: it is a tool-plugin env var +# that mise only injects once the dotnet tool is active, so it is unset while the config loads (e.g. mid +# `mise install`). The version segment is the [tools] dotnet pin above -- bump together. dotnet_msbuild = "$$DOTNET_ROOT/sdk/10.0.300" # The publish-and-stage step both C# WASM modules share. # `set -x` echoes every command: the Windows lane is newly re-enabled, so keep the trace until it is reliably @@ -32,9 +30,6 @@ dotnet_msbuild = "$$DOTNET_ROOT/sdk/10.0.300" # while the process-environment PATH stays untouched. The compile step never needed this (absolute emcc path, # no PATH lookup), and emcc's own subprocesses find python/node via EMSDK_PYTHON / DOTNET_EMSCRIPTEN_NODE_JS # rather than PATH -- verified: the compile succeeds even when cmd can read no PATH whatsoever. -# -# Every `$` here is doubled: a [vars] value goes through mise's config-load `$VAR` expansion (a task `run` body -# does not), so a bare `$PUBLISH` would resolve against the loading environment and warn when unset. dotnet_publish_sh = ''' set -x diff --git a/.mise/config.js.toml b/.mise/config.js.toml index 562a9bf9..2aa31fd1 100644 --- a/.mise/config.js.toml +++ b/.mise/config.js.toml @@ -107,8 +107,8 @@ url = "https://github.com/oxc-project/oxc/releases/download/apps_v{{version}}/ox # https://github.com/edge-toolkit/core/actions/runs/39880984044/job/120178425913 (check windows-latest lane). # Inside the install, aube hoists the platform package into the flat `node_modules/.mise/node_modules` farm. tsgolint_env = """{% if os() == "windows" -%} -tsgolint="$(mise where npm:oxlint-tsgolint)/node_modules/.mise/node_modules/@oxlint-tsgolint/win32-x64" -export OXLINT_TSGOLINT_PATH="$tsgolint/tsgolint.exe" +tsgolint="$$(mise where npm:oxlint-tsgolint)/node_modules/.mise/node_modules/@oxlint-tsgolint/win32-x64" +export OXLINT_TSGOLINT_PATH="$$tsgolint/tsgolint.exe" {%- endif -%} """ diff --git a/.mise/config.r.toml b/.mise/config.r.toml index 9c0bc2af..2f09bccc 100644 --- a/.mise/config.r.toml +++ b/.mise/config.r.toml @@ -22,9 +22,6 @@ version = "0.6.0" # module's pkg/webr/. `http:webr` downloads and unpacks it flat; copy that install dir into the module's # pkg/webr/ (dropping mise's own metadata.json). The module's package.json makes pkg/ a served package, so the # copied webr/ subtree rides along as static assets under /modules//webr/. -# -# Every `$` here is doubled: a [vars] value goes through mise's config-load `$VAR` expansion (a task `run` body -# does not), so a bare `$src` would resolve against the loading environment and warn when unset. webr_vendor_sh = """ src="$$(mise where "http:webr")" [ -n "$$src" ] || { diff --git a/.mise/config.toml b/.mise/config.toml index 092ac9a2..32a6f238 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -29,6 +29,11 @@ cargo.binstall = true # proactively iterating every registry tool with `idiomatic_files` declared. The rest (forgejo, gem, gitlab, # pkgx, s3, spm, ubi) are belt-and-braces. disable_backends = ["forgejo", "gem", "gitlab", "pkgx", "s3", "spm", "ubi", "vfox"] +# Pinned to its default, because every [vars] shell snippet relies on it. +# Each literal `$` in one is written `$$`, and only this expansion turns it back into one; with it off, the `$$` +# reaches the shell, which reads it as its own PID. Pinning it outranks a user's global `false`; only an explicit +# MISE_ENV_SHELL_EXPAND=false still overrides it. +env_shell_expand = true exec_auto_install = false experimental = true http_retries = 10 diff --git a/.mise/config.zig.toml b/.mise/config.zig.toml index 61ab155c..5b8039da 100644 --- a/.mise/config.zig.toml +++ b/.mise/config.zig.toml @@ -56,8 +56,7 @@ version = "20261006" # xargs: ...\.mise-bins\clang-format.cmd: exited with status 255; aborting # Naming the conda-forge binary under `Library/bin` instead keeps cmd.exe out of the chain entirely, so the limit # becomes CreateProcess's 32767 and no amount of PATH growth reaches it. Same shape as the `llvm_ct` line below, -# which has always addressed llvm-mingw's clang-tidy by absolute path. Every `$` is doubled because a [vars] value -# goes through mise's config-load `$VAR` expansion, which a task `run` body does not. +# which has always addressed llvm-mingw's clang-tidy by absolute path. # # `Library/bin` holds only for a native conda package; the msys2 mirror ones (every `conda:m2-*`) put their # binaries under `Library/usr/bin` instead, so this resolves the two clang tools and nothing else by luck. Both diff --git a/.mise/shellcheck-mise.jq b/.mise/shellcheck-mise.jq index 7065e7fe..9a484359 100644 --- a/.mise/shellcheck-mise.jq +++ b/.mise/shellcheck-mise.jq @@ -16,9 +16,9 @@ # Shell held in a [vars] entry, which a task references instead of spelling the body out. # Named by the `_sh` suffix: a [vars] value is just a string, so nothing else tells shell apart from the # Tera-templated paths beside it, and masking one of those would leave shellcheck reading fragments. `$$` -# collapses back to `$` because a [vars] value doubles every dollar to survive mise's config-load expansion, -# and it is the shell's `$` that shellcheck has to see. Each branch is parenthesised because `,` binds tighter -# than `|`, so an unbracketed second stream would run against the first one's output instead of the input. +# collapses back to `$`, since it is the shell's `$` that shellcheck has to see. Each branch is parenthesised because +# `,` binds tighter than `|`, so an unbracketed second stream would run against the first one's output instead of +# the input. ( (.vars // {}) | to_entries[] | diff --git a/CHECKS.md b/CHECKS.md index eebbfae8..f814cc95 100644 --- a/CHECKS.md +++ b/CHECKS.md @@ -5,7 +5,7 @@ - **Tools:** 49 - **Shell-script checks:** 10 - **Checks with a repair pass:** 22 -- **Rules:** 377 custom local rules, or non-default strict linter settings +- **Rules:** 378 custom local rules, or non-default strict linter settings - **Meta rules:** 21 of those rules are rules about rules Every check is listed across every `MISE_ENV`, filed under the tool it runs. Each entry names the env whose config @@ -738,6 +738,7 @@ Rules, from `config/conftest/policy`: - No per-platform URL of an `http:` tool may point at a forge either. - A tool must not be os-scoped unless allowlisted, since every tool must work on every OS. - A mise install path that a config string embeds must name the version [tools] pins. + - A [vars] value must double every `$` that is meant for a shell. - python must be pinned to a full version triple (X.Y.Z), not a minor alias. - Each OS's preinstall task must read its package lists from the Dockerfile ARGs. - Every usage arg a task declares must be read by its run body. diff --git a/config/conftest/policy/mise/mise.rego b/config/conftest/policy/mise/mise.rego index 3f245bc9..5603818b 100644 --- a/config/conftest/policy/mise/mise.rego +++ b/config/conftest/policy/mise/mise.rego @@ -302,13 +302,20 @@ tool_versions contains [dir, version] if { } # Every [vars]/[env] string, tagged with where it came from for the message. +# An entry is either the string itself or mise's directive table, whose `default` mise loads the same way. config_strings contains entry if { some file in input is_mise(file) some kind in ["vars", "env"] some key, value in object.get(file.contents, kind, {}) - is_string(value) - entry := {"path": file.path, "kind": kind, "key": key, "value": value} + entry := {"path": file.path, "kind": kind, "key": key, "value": entry_string(value)} +} + +entry_string(value) := value if is_string(value) + +entry_string(value) := value.default if { + is_object(value) + is_string(value.default) } # An install path embeds a tool's version as the segment right after the tool's install dir. @@ -337,6 +344,31 @@ deny contains msg if { ) } +# A [vars] value must double every `$` that is meant for a shell. +# mise expands `$NAME` and `${NAME}` in a [vars] value against the environment it loads the config in, before any +# task runs; a task `run` body gets no such pass. A shell snippet kept in [vars] -- a variable it sets, a `${OS:-}` +# guard -- therefore reaches mise first, which substitutes a name the loading environment happens to set and leaves +# an unset one in place, warning on every load: +# mise WARN env var 'tsgolint' is not defined and will be left unexpanded, referenced by `tsgolint_env` in +# D:\a\core\core\.mise\config.js.toml. Use ${tsgolint:-} to default to an empty string and suppress this warning. +# `$$` is mise's escape for a literal `$`, so write `$$name` for the shell, and `{{ env.NAME }}` to read the loading +# environment on purpose. An odd run of `$` before a name or `{` is the unescaped case; `$(` and `$1` are never +# expanded, so either spelling passes. A warning only fires where the template around the snippet renders it, which +# can be one OS alone, so this reads the TOML unrendered to catch it on every lane. Check-only: conftest has no fix +# pass, and the right rewrite depends on whether the value meant the shell or the loading environment. +unescaped_dollar := `(?:^|[^$])(?:\$\$)*(\$\{?[A-Za-z_][A-Za-z0-9_]*)` + +deny contains msg if { + some entry in config_strings + entry.kind == "vars" + tokens := {m[1] | some m in regex.find_all_string_submatch_n(unescaped_dollar, entry.value, -1)} + count(tokens) > 0 + msg := sprintf( + "%s: [vars] %q has unescaped %s -- write $$ for the shell, or {{ env.NAME }} for the environment", + [entry.path, entry.key, concat(", ", sort(tokens))], + ) +} + tool_version_str(spec) := spec if is_string(spec) tool_version_str(spec) := spec.version if is_object(spec) diff --git a/config/conftest/policy/mise/mise_test.rego b/config/conftest/policy/mise/mise_test.rego index a2b2cc4e..223db110 100644 --- a/config/conftest/policy/mise/mise_test.rego +++ b/config/conftest/policy/mise/mise_test.rego @@ -100,6 +100,53 @@ test_underscore_flattened_install_dir_is_recognised if { reports(msgs, "keep them in sync") } +# mise expands `$name` in a [vars] value when it loads the config, so a shell variable there must be `$$name`. +# The first case is the tsgolint_env regression itself: the command substitution escaped, the variable not. +test_bare_shell_variable_in_a_var_is_flagged if { + value := "dir=\"$$(mise where npm:x)\"\nexport X_PATH=\"$dir/x.exe\"" + msgs := mise.deny with input as config(".mise/config.toml", {"vars": {"x_env": value}}) + reports(msgs, `[vars] "x_env" has unescaped $dir`) +} + +test_braced_expansion_in_a_var_is_flagged if { + value := `if [ "${OS:-}" = "Windows_NT" ]; then echo win; fi` + msgs := mise.deny with input as config(".mise/config.toml", {"vars": {"os_sh": value}}) + reports(msgs, "has unescaped ${OS") +} + +# mise's directive-table form loads its `default` exactly as it loads a plain string value. +test_table_form_default_in_a_var_is_flagged if { + contents := {"vars": {"x_env": {"default": "echo $HOME"}}} + msgs := mise.deny with input as config(".mise/config.toml", contents) + reports(msgs, `[vars] "x_env" has unescaped $HOME`) +} + +# `$$` pairs off first, so a third `$` is still a bare one: mise renders `$$$x` as `$` then expands `$x`. +test_odd_run_of_dollars_in_a_var_is_flagged if { + msgs := mise.deny with input as config(".mise/config.toml", {"vars": {"odd": "echo $$$x"}}) + reports(msgs, "has unescaped $x") +} + +test_doubled_dollars_in_a_var_are_accepted if { + value := `src="$$(mise where x)"; [ "$${OS:-}" = "Windows_NT" ] && echo "$$src"` + msgs := mise.deny with input as config(".mise/config.toml", {"vars": {"x_sh": value}}) + count(msgs) == 0 +} + +# mise leaves a command substitution and a positional parameter alone, so neither needs escaping. +test_command_substitution_and_positionals_in_a_var_are_accepted if { + value := "dirname $(xcrun --find clang) $1" + msgs := mise.deny with input as config(".mise/config.toml", {"vars": {"clang": value}}) + count(msgs) == 0 +} + +# A task `run` body gets no config-load expansion, and an [env] value names the environment on purpose. +test_run_bodies_and_env_values_keep_single_dollars if { + contents := object.union(task("probe", {"run": "echo $HOME"}), {"env": {"TOOL_HOME": "$HOME/tool"}}) + msgs := mise.deny with input as config(".mise/config.toml", contents) + count(msgs) == 0 +} + # A multiline run needs the strict shell, or a failing command in the middle is masked. test_multiline_run_without_the_strict_shell_is_flagged if { msgs := mise.deny with input as config(".mise/config.toml", task("build", {"run": "cd x\nmake"}))