Skip to content

fix(run): execute local files instead of mis-routing to package-exec#75

Merged
kjanat merged 8 commits into
masterfrom
fix/run-path-local-file-execution
Jun 28, 2026
Merged

fix(run): execute local files instead of mis-routing to package-exec#75
kjanat merged 8 commits into
masterfrom
fix/run-path-local-file-execution

Conversation

@kjanat

@kjanat kjanat commented Jun 27, 2026

Copy link
Copy Markdown
Owner

Problem

run <path> / runner run <path> had no local-file detection. A path
token was looked up as a task name and, on no match, hit the PM-exec
fallback (build_pm_exec_command). What happened then was an accident of
package-manager detection:

  • With a node PM detected, the token became <pm> x <path>, which resolves
    the local path as a remote package:
    • run bin/recipe-tmlang.ts -> bun bin/recipe-tmlang.ts -> registry 404
    • run ./bin/recipe-tmlang.ts -> bun ./bin/recipe-tmlang.ts -> git clone failure
  • With no node PM, the token was spawned directly, so a non-executable file
    with a shebang died with Permission denied.

The executable bit and shebang were never consulted. Adding ./ changed
nothing.

Change

New module src/cmd/run/local_file.rs, wired into resolve_dispatch in two
places:

  • Explicit path token (carries a separator or a .//..////~
    prefix) is handled at the top, before task lookup and PM resolution,
    so an explicit path outranks a same-named task. A directory falls through
    (e.g. so go run ./cmd/foo is unaffected); a missing explicit path
    reports a clear no such file error instead of a 404; a separator-bearing
    remote spec like @scope/pkg or github.com/owner/tool (no local prefix,
    not on disk) still falls through to package-exec.
  • Bare filename is handled after task lookup misses but before the
    PM-exec fallback, so a bare name resolves to a task first (no collisions)
    yet a local script such as main.ts still runs rather than being treated
    as a remote package.

Classification of an existing regular file:

  1. Executable bit (Unix) or native extension (Windows .exe/.com) -> spawn directly.
  2. #! shebang -> parse the interpreter, including #!/usr/bin/env -S <interp> <args>, and invoke <interp> [args] <file> <forwarded>.
  3. Recognized source extension -> detected runtime, never package-exec:
    .ts/.tsx/.mts/.cts/.js/.jsx/.mjs/.cjs via bun, deno run,
    or node (per detected project / --pm); .py via uv run or python;
    .go via go run; Windows .ps1/.bat/.cmd via PowerShell / cmd /c.
  4. Otherwise a clear, actionable error.

Hard rule honored: a token that resolves to an existing local file never
reaches bunx/npx/pnpm dlx/deno x/uvx. Reuses configure_command
for cwd + node_modules/.bin PATH, and adds run_file_cmd helpers to the
bun/deno/node/uv/python/go tool modules. expand_tilde is promoted to
pub(crate) for reuse.

Verification

Gates (all green):

  • cargo build
  • cargo test --lib -> 727 passed
  • cargo clippy --all-targets --all-features -> 0 warnings
  • dprint check -> exit 0

End-to-end with the built run binary across fixture projects:

  • bun project, sub/hello.ts arg1 -> bun sub/hello.ts arg1 (not bunx), ran
  • bun project, ./build.sh foo (exec) -> exec, ran
  • bun project, ./noexec.py x y (shebang, no +x) -> python3 ./noexec.py x y, ran
  • deno project, bare main.ts a b -> deno run main.ts a b (not deno x), ran
  • deno project, ./scr.ts with #!/usr/bin/env -S deno run -A -> deno run -A ./scr.ts, ran
  • npm project, bare x.mjs hi -> node x.mjs hi (not npx), ran
  • go project, ./tool.go zzz -> go run ./tool.go zzz, ran
  • bare deploy (task) runs the task; explicit ./deploy runs the file
  • missing ./nope.sh -> Error: no such file: ./nope.sh (not Permission denied / 404)
  • @scope/... (remote spec) still routes to package-exec

Closes #69

`run <path>` / `runner run <path>` had no local-file detection: a path
token fell through to the PM-exec fallback, where a node PM turned it into
`<pm> x <path>` (resolving the local path as a remote package -> registry
404 / `git clone` failure) and a non-node project spawned it directly, so
the executable bit and shebang were never consulted.

Add a local-file dispatch path (`cmd::run::local_file`) wired into
`resolve_dispatch` in two places:

- An explicit path-like token (separator or `./`/`/`/`~` prefix) is checked
  at the top, before task lookup, so an explicit path outranks a same-named
  task. A missing explicit path reports a clear error rather than a 404.
- A bare filename is checked after task lookup misses but before the
  PM-exec fallback, so a task always wins for a bare name yet a local
  script such as `main.ts` still runs.

Classification: executable bit (Unix) or native extension (Windows) ->
spawn directly; `#!` shebang (including `#!/usr/bin/env -S <interp> <args>`)
-> parse interpreter and invoke; recognized source extension -> detected
runtime (`.ts`/`.tsx`/`.js`/`.mjs`/`.cjs` via bun / `deno run` / node;
`.py` via `uv run` / python; `.go` via `go run`); otherwise a clear error.
A token resolving to an existing local file never reaches
`bunx`/`npx`/`pnpm dlx`/`deno x`/`uvx`. Reuses `configure_command` for cwd
+ `node_modules/.bin` PATH and new `run_file_cmd` helpers on the runtime
tool modules.

Verification: `cargo build`, `cargo test --lib` (727 pass), `cargo clippy
--all-targets --all-features` (0 warnings), and `dprint check` all green.
Exercised the built `run` binary end to end: subdir `.ts` runs via bun
(not bunx), bare `main.ts` runs via `deno run` (not `deno x`), `.mjs` via
node (not npx), `.go` via `go run`, an exec script and a no-`+x` shebang
script both run, a bare task name still beats a same-named file, an
explicit `./deploy` beats a same-named task, a missing `./nope.sh` errors
cleanly, and a remote `@scope/pkg` spec still reaches package-exec.

Closes #69
@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one) (1)
  • cr:review
🚫 Excluded labels (none allowed) (2)
  • wip
  • cr:skip

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 39675e68-c9d5-4226-9b6a-e52362612089

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds local-file execution for run <path> and runner run <path>. Explicit local-path tokens and bare path-like misses are resolved before package-exec fallback, then dispatched via direct execution, shebang parsing, or runtime-specific file commands. precheck_task now accepts explicit local-path prefixes early. Helper commands were added for bun, deno, go, node, Python, and uv, and expand_tilde was made crate-visible for path resolution.

Sequence Diagram(s)

sequenceDiagram
  participant CLI as resolve_dispatch
  participant Qualify as precheck_task
  participant LocalFile as local_file.rs
  participant Tool as runtime helpers
  participant Spawn as Command

  CLI->>LocalFile: try_path_token / try_bare_file
  Qualify->>Qualify: accept explicit-prefix local paths
  LocalFile->>Tool: build runtime or interpreter command
  Tool->>Spawn: return configured Command
  CLI->>Spawn: dispatch local file
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • KAJ-268: The changes directly implement the local-file dispatch fix for run <path>, including path detection, shebang/runtime handling, and avoiding package-exec fallback.

Suggested labels

bug

Poem

Aye, the path token’s no longer sent adrift,
Nor fed to package-exec’s thieving gift.
Shebangs sing, runtimes roar,
And local files sail proper evermore.

🚥 Pre-merge checks | ✅ 7 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Semver Version Bump Validation ⚠️ Warning Source files changed, but the versioned manifests stayed put (Cargo.toml 0.14.3; site/package.json 0.0.0), so no SemVer bump was made. Add a SemVer version bump in one allowed version file; for this bug-fix PR, PATCH should increment.
✅ Passed checks (7 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the local-file dispatch bug fix and matches the main change set.
Description check ✅ Passed The description directly matches the local-file execution fix and its runtime-handling details.
Linked Issues check ✅ Passed The implementation matches #69 with local-file precedence, shebang/runtime execution, and no package-exec routing.
Out of Scope Changes check ✅ Passed I don't see unrelated code changes beyond the support helpers needed for the local-file fix.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 30.00%.
Changelog Update ✅ Passed Code files were changed, and CHANGELOG.md was updated under the existing [Unreleased] section with Added/Fixed entries; no version bump is present.
Agents.Md Documentation Updated ✅ Passed No AGENTS.md files exist anywhere in the repository, so there was nothing to update for this PR.
📋 Issue Planner

Built with CodeRabbit's Coding Plans for faster development and fewer bugs.

View plan used: #69


Comment @coderabbitai help to get the list of available commands.

kjanat added 4 commits June 27, 2026 20:57
…tive paths

build_command read the exec bit before the shebang/runtime check, so a +x
shebang-less .ts/.js/.py/.go (common on vfat/exfat/ntfs-3g mounts that report
mode 0777 for every file) was spawned via raw execve and failed ENOEXEC
instead of reaching bun/deno/node/uv/python/go. Read the shebang up front and
take the direct-exec branch only for a real shebang or a non-source binary;
a recognized source file with no shebang now runs via its runtime.

The pre-task local-file short-circuit fired for any separator-bearing token,
so once a make target like bin/tool produced its output file, run bin/tool
spawned the stale artifact (or errored) instead of running make bin/tool.
Gate the pre-task branch on an explicit local prefix (./ ../ / \ ~ or a
Windows drive root); route prefix-less relative paths through the
after-task-miss fallback so a matching task wins first.
- try_bare_file + resolve_path now join relative/bare tokens onto ctx.root
  (the --dir/RUNNER_DIR target task detection scans and the child runs in),
  not the live cwd; fixes --dir mis-routing a relative token into the
  bunx 404 fallback and running a wrong same-named cwd file.
- precheck_task gains the same explicit-prefix escape hatch as dispatch
  (has_local_prefix, now pub(super)) so a chain / install --parallel
  under --runner / [task_runner].prefer no longer rejects ./gen.sh.
- fix module doc: dead [`try_build`] link -> try_path_token / try_bare_file.
read_shebang no longer hard-fails when a file cannot be opened for read:
an execute-only binary (Unix mode 0111) can be execve'd but returns EACCES
on open(O_RDONLY). Treat that as 'no shebang' and defer to the exec bit /
extension, so a 0111 native binary spawns directly and a 0111 recognized
source routes to its runtime instead of bunx. Signature simplifies to
Option<Shebang>.

.jsx/.tsx no longer route to bare node, which has no JSX transform and
type-strips only .ts/.mts/.cts -> 'node app.tsx' is categorically broken.
They now route via bun/deno when detected, else surface a clear
'node cannot run' error (outcome 4) rather than an unrunnable command.
bare_file_in did build_command(...).ok(), swallowing the classification
error for a recognized source file the runtime cannot run (e.g. .tsx in a
node-only project). The None then fell through to build_pm_exec_command,
producing pnpm exec app.tsx / npx app.tsx -> registry 404 on a file that
exists on disk — the exact #69 mis-route, and an explicit-vs-bare split
(run ./app.tsx errored cleanly, run app.tsx 404'd).

try_bare_file/bare_file_in now return Result<Option<LocalDispatch>>,
mirroring the explicit ./ path: propagate build_command's error unless the
extension is genuinely Unrecognized (data.bin still falls through to
PM-exec; EACCES execute-only source still routes to its runtime). Add a
regression test: a bare app.tsx in a Pnpm project must Err 'node cannot
run', never build a pnpm/npx command.
@kjanat kjanat self-assigned this Jun 27, 2026
@kjanat kjanat added bug Something isn't working area: cli Argument parsing, commands, and CLI UX labels Jun 27, 2026
viktoriavikulya

This comment was marked as spam.

@kjanat kjanat added the cr:review Allow CodeRabbit review label Jun 27, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/cmd/run/dispatch.rs`:
- Around line 243-257: The bare-file fallback in dispatch is being checked too
late, after runner_constraint_error and resolve_node_pm can already fail. Move
the try_bare_file probe in dispatch::Dispatch around the local-file path so it
runs before the PM/task fallback gates, preserving local runnable files like
main.ts even in constrained projects and before any unrelated PM resolution
error can abort dispatch.

In `@src/cmd/run/local_file.rs`:
- Around line 138-143: The error handling in build_command/routing_for_extension
is letting existing bare local files fall back to Ok(None), which can route them
into PM-exec instead of failing as unsupported local files. Update the match in
local_file.rs so that when the path already resolves to a real local file,
unsupported cases return the local-file error from build_command rather than
treating SourceRouting::Unrecognized as None; keep the fallback to Ok(None) only
for non-local inputs. Use build_command and routing_for_extension as the key
symbols when adjusting this branch.
- Around line 378-391: The shebang parsing in local_file::Shebang handling is
re-splitting arguments with split_whitespace, which breaks env -S quoting and
incorrectly splits non--S shebang arguments. Update the parsing logic in the
is_env branch and the fallback args collection to preserve kernel-style shebang
semantics: treat non--S env arguments as a single argument string, and only
apply env -S parsing rules where appropriate without losing quoted substrings.
Use the existing Shebang construction path in local_file.rs to keep program and
args aligned with what env and the kernel would receive.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: cfa317cb-a5d8-4d7e-bd35-ca8ec0f4c455

📥 Commits

Reviewing files that changed from the base of the PR and between 650a679 and d2c783e.

📒 Files selected for processing (12)
  • CHANGELOG.md
  • src/cmd/run.rs
  • src/cmd/run/dispatch.rs
  • src/cmd/run/local_file.rs
  • src/cmd/run/qualify.rs
  • src/lib.rs
  • src/tool/bun.rs
  • src/tool/deno.rs
  • src/tool/go_pm.rs
  • src/tool/node.rs
  • src/tool/python.rs
  • src/tool/uv.rs
📜 Review details
⚠️ CI failures not shown inline (2)

GitHub Actions: verify PR #75 / 9_verify.txt: verify PR #75

Conclusion: failure

View job details

##[group]Run bail() {
 �[36;1mbail() {�[0m
 �[36;1m  printf '::error::install-action: %s\n' "$*"�[0m

GitHub Actions: PR #75 / 0_Analyze (rust).txt: PR #75

Conclusion: failure

View job details

failed for 'assert'
 [] [build-stdout] [] [build-stdout] �[33m WARN�[0m /home/runner/work/runner/runner/src/types.rs:954:9: macro expansion failed for 'assert'
 [] [build-stdout] [] [build-stdout] �[33m WARN�[0m /home/runner/work/runner/runner/src/types.rs:955:9: macro expansion failed for 'assert'
 [] [build-stdout] [] [build-stdout] �[33m WARN�[0m /home/runner/work/runner/runner/src/types.rs:956:9: macro expansion failed for 'assert'
 [] [build-stdout] [] [build-stdout] �[33m WARN�[0m /home/runner/work/runner/runner/src/types.rs:963:9: macro expansion failed for 'assert'
 [] [build-stdout] [] [build-stdout] �[33m WARN�[0m /home/runner/work/runner/runner/src/types.rs:964:9: macro expansion failed for 'assert'
 [] [build-stdout] [] [build-stdout] �[33m WARN�[0m /home/runner/work/runner/runner/src/types.rs:965:9: macro expansion failed for 'assert'
 [] [build-stdout] [] [build-stdout] �[33m WARN�[0m /home/runner/work/runner/runner/src/types.rs:966:9: macro expansion failed for 'assert'
 [] [build-stdout] [] [build-stdout] �[33m WARN�[0m /home/runner/work/runner/runner/src/types.rs:967:9: macro expansion failed for 'assert'
 [] [build-stdout] [] [build-stdout] �[33m WARN�[0m /home/runner/work/runner/runner/src/types.rs:972:9: macro expansion failed for 'assert'
 [] [build-stdout] [] [build-stdout] �[33m WARN�[0m /home/runner/work/runner/runner/src/types.rs:973:9: macro expansion failed for 'assert'
 [] [build-stdout] [] [build-stdout] �[33m WARN�[0m /home/runner/work/runner/runner/src/types.rs:978:9: macro expansion failed for 'assert'
 [] [build-stdout] [] [build-stdout] �[33m WARN�[0m /home/runner/work/runner/runner/src/types.rs:983:9: macro expansion failed for 'assert'
 [] [build-stdout] [] [build-stdout] �[33m WARN�[0m /home/runner/work/runner/runner/src/types.rs:984:9: macro expansion failed for 'assert'
 [] [build-stdout] [] [build-stdout] �[33m WARN�[0m /home/runner/work/runner/runner/src/types.rs:985:9: macro expansion failed for 'assert'
 [] [build-stdout...
🧰 Additional context used
📓 Path-based instructions (1)
**/CHANGELOG.md

📄 CodeRabbit inference engine (Custom checks)

**/CHANGELOG.md: If any source code files (excluding tests, docs, CI, markdown, or comments-only changes) are modified, CHANGELOG.md MUST also be modified in the same PR.
If a version bump is detected, CHANGELOG.md MUST contain a new section header matching the exact new version number in the format: '## [X.Y.Z] - YYYY-MM-DD'.
If NO version bump is detected, the changes in the PR MUST be added under the existing '## [Unreleased]' section in CHANGELOG.md. The entry MUST describe the changes (e.g., Added, Changed, Fixed, Removed).

Files:

  • CHANGELOG.md
🧠 Learnings (7)
📚 Learning: 2026-03-26T20:05:44.851Z
Learnt from: kjanat
Repo: kjanat/runner PR: 1
File: src/cmd/mod.rs:67-75
Timestamp: 2026-03-26T20:05:44.851Z
Learning: In Rust, `std::process::Command` does not provide getters for stdio configuration, so whether `Stdio::inherit()` (or other `Stdio::*` settings) was applied cannot be asserted via pure unit tests without spawning a process and inspecting OS-level fds. When reviewing Rust code, do not flag “missing unit test coverage” for stdio configuration on `std::process::Command` as a code issue—treat the explicit `Command::stdin/stdout/stderr` setter calls in the source as the meaningful guarantee.

Applied to files:

  • src/tool/bun.rs
  • src/lib.rs
  • src/tool/deno.rs
  • src/cmd/run.rs
  • src/tool/go_pm.rs
  • src/cmd/run/dispatch.rs
  • src/tool/python.rs
  • src/cmd/run/qualify.rs
  • src/tool/uv.rs
  • src/tool/node.rs
  • src/cmd/run/local_file.rs
📚 Learning: 2026-04-21T15:16:40.277Z
Learnt from: kjanat
Repo: kjanat/runner PR: 4
File: src/tool/just.rs:132-133
Timestamp: 2026-04-21T15:16:40.277Z
Learning: In Rust, if a method like `ExtractedTask::name() -> &str` returns a borrowed `&str` tied to `&self`, then using `sort_unstable_by_key(|t| t.name())` should be avoided because `by_key` requires the key to not borrow from the element being sorted (it will fail to compile due to the key’s lifetime). Do not recommend `sort_unstable_by_key` as a simplification in this situation. If you need an allocation-free and idiomatic comparison, use `sort_unstable_by(|a, b| a.name().cmp(b.name()))` instead. Using `.to_owned()` inside `by_key` is an alternative but allocates a `String` per element.

Applied to files:

  • src/tool/bun.rs
  • src/lib.rs
  • src/tool/deno.rs
  • src/cmd/run.rs
  • src/tool/go_pm.rs
  • src/cmd/run/dispatch.rs
  • src/tool/python.rs
  • src/cmd/run/qualify.rs
  • src/tool/uv.rs
  • src/tool/node.rs
  • src/cmd/run/local_file.rs
📚 Learning: 2026-05-04T23:28:17.947Z
Learnt from: kjanat
Repo: kjanat/runner PR: 5
File: src/lib.rs:222-223
Timestamp: 2026-05-04T23:28:17.947Z
Learning: When reviewing Rust `rustdoc` comments (`/// ...`), treat a trailing backslash (`\`) at the end of a comment line as valid CommonMark syntax for a hard line break (rendered as `<br>`). Do not flag such trailing backslashes as papercuts or recommend removing them unless there is clear evidence they are unintended (e.g., they are not in the `rustdoc` comment context or the surrounding formatting contradicts the intended CommonMark hard-break usage).

Applied to files:

  • src/tool/bun.rs
  • src/lib.rs
  • src/tool/deno.rs
  • src/cmd/run.rs
  • src/tool/go_pm.rs
  • src/cmd/run/dispatch.rs
  • src/tool/python.rs
  • src/cmd/run/qualify.rs
  • src/tool/uv.rs
  • src/tool/node.rs
  • src/cmd/run/local_file.rs
📚 Learning: 2026-06-01T17:42:48.461Z
Learnt from: kjanat
Repo: kjanat/runner PR: 34
File: Cargo.toml:61-61
Timestamp: 2026-06-01T17:42:48.461Z
Learning: In this repo, do not flag `actions_rs::log::GroupGuard` or `actions_rs::log::group_guard` as missing/non-existent when they are referenced in Rust code, because the `actions-rs` crate (dependency `actions-rs = "0.1"` / published v0.1.x) exports `pub struct GroupGuard` and `pub fn group_guard` from `actions_rs::log` (and also exports `actions_rs::env::is_github_actions`). This avoids false positives from outdated/incorrect web search results. If the repo does not depend on `actions-rs = "0.1"` in `Cargo.toml`, then normal missing-import/item checks can apply.

Applied to files:

  • src/tool/bun.rs
  • src/lib.rs
  • src/tool/deno.rs
  • src/cmd/run.rs
  • src/tool/go_pm.rs
  • src/cmd/run/dispatch.rs
  • src/tool/python.rs
  • src/cmd/run/qualify.rs
  • src/tool/uv.rs
  • src/tool/node.rs
  • src/cmd/run/local_file.rs
📚 Learning: 2026-06-11T18:52:28.233Z
Learnt from: kjanat
Repo: kjanat/runner PR: 45
File: src/lib.rs:659-661
Timestamp: 2026-06-11T18:52:28.233Z
Learning: In Rust, it’s acceptable to use `matches!(cli.command, Some(cli::Command::Doctor { .. }))` (or similar) when the scrutinee comes from a field accessed through a shared reference (e.g., `cli: &Cli`). If the match pattern uses `..` and binds zero fields (so only the enum discriminant is matched), Rust match ergonomics will avoid moving out of the referenced value. Reviewers should not flag such code as a compile-blocking move; confirm with `cargo check` rather than forcing changes like `.as_ref()` when compilation succeeds.

Applied to files:

  • src/tool/bun.rs
  • src/lib.rs
  • src/tool/deno.rs
  • src/cmd/run.rs
  • src/tool/go_pm.rs
  • src/cmd/run/dispatch.rs
  • src/tool/python.rs
  • src/cmd/run/qualify.rs
  • src/tool/uv.rs
  • src/tool/node.rs
  • src/cmd/run/local_file.rs
📚 Learning: 2026-05-15T01:31:48.037Z
Learnt from: kjanat
Repo: kjanat/runner PR: 27
File: src/types.rs:584-585
Timestamp: 2026-05-15T01:31:48.037Z
Learning: In the `kjanat/runner` Rust codebase, remember that `just` treats its `justfile` name as officially case-insensitive and allows the hidden variant: `justfile`, `Justfile`, `JUSTFILE`, etc., and `.justfile`. `TaskSource::Justfile` detection should recognize these filename variants. Also note that `from_label` in `src/types.rs` is for parsing user-supplied qualifier prefixes (e.g. `justfile:build`), not for detecting the on-disk justfile. If you add/adjust qualifier label emission for `TaskSource::Justfile`, it’s not strictly required for backward compatibility because `TaskSource::label()` previously only emitted lowercase `"justfile"`, but adding additional casing/hidden variants as a defensive UX improvement is consistent with `just`’s official support.

Applied to files:

  • src/tool/bun.rs
  • src/lib.rs
  • src/tool/deno.rs
  • src/cmd/run.rs
  • src/tool/go_pm.rs
  • src/cmd/run/dispatch.rs
  • src/tool/python.rs
  • src/cmd/run/qualify.rs
  • src/tool/uv.rs
  • src/tool/node.rs
  • src/cmd/run/local_file.rs
📚 Learning: 2026-05-14T15:35:39.922Z
Learnt from: kjanat
Repo: kjanat/runner PR: 27
File: src/cmd/run.rs:206-212
Timestamp: 2026-05-14T15:35:39.922Z
Learning: For Go projects using the canonical `cmd/` layout, when `runner` dispatches a local Go tool by its bare name (e.g., `some-cli`) under `PackageManager::Go`, resolve it relative to `ctx.root` by probing `ctx.root/cmd/<task_name>/`. If it exists, run the tool with the filesystem-path form: `go run ./cmd/<task_name>` (note the leading `./`). Do NOT generate `go run <task_name>` because bare names are treated as Go import paths, not local directories. While `go run <module>/cmd/<task_name>` can work, it’s unnecessary for local development—prefer `./cmd/<task_name>`.

Applied to files:

  • src/cmd/run.rs
  • src/cmd/run/dispatch.rs
  • src/cmd/run/qualify.rs
  • src/cmd/run/local_file.rs
🔇 Additional comments (9)
CHANGELOG.md (1)

20-48: LGTM!

src/lib.rs (1)

602-602: LGTM!

src/cmd/run.rs (1)

28-28: LGTM!

src/tool/bun.rs (1)

42-55: LGTM!

Also applies to: 77-89

src/tool/deno.rs (1)

219-229: LGTM!

Also applies to: 263-283

src/tool/go_pm.rs (1)

161-189: LGTM!

src/tool/node.rs (1)

5-5: LGTM!

Also applies to: 33-41, 597-617

src/tool/python.rs (1)

5-24: LGTM!

Also applies to: 156-171

src/tool/uv.rs (1)

49-63: LGTM!

Also applies to: 95-107

Comment thread src/cmd/run/dispatch.rs Outdated
Comment thread src/cmd/run/local_file.rs Outdated
Comment thread src/cmd/run/local_file.rs Outdated
kjanat added a commit that referenced this pull request Jun 28, 2026
CodeRabbit review on #75 — two valid findings fixed, one skipped.

dispatch: move the bare local-file probe ahead of node-PM resolution (but
still after the `--runner` constraint hard error), so a local file runs
when PM resolution would hard-error for reasons unrelated to it (a strict
devEngines/packageManager mismatch, an incompatible `--pm`) — running
`main.ts` via its runtime never needed the package.json PM. The explicit
`--runner` constraint still hard-errors, never silently downgrading to a
coincidental file.

shebang: parse the `env -S` / `--split-string` form with a quote-aware
splitter instead of `split_whitespace`, so quoted arguments
(`-S deno run --allow-read="a b"`) stay a single token.

Skipped: routing an *unrecognized* existing bare file to an error instead
of PM-exec. That fallthrough is deliberate and pinned by
`bare_file_ignores_missing_and_non_script_tokens`; #69's protection already
errors on recognized-but-unrunnable files (e.g. a bare `.tsx` on node).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
src/cmd/run/local_file.rs (2)

1266-1286: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Existing bare local files still leak into PM-exec.

Arrr, this test locks in the exact behaviour the PR says to kill: once data.bin has resolved to a real local file, returning None lets runner run data.bin drift back into bunx/npx/pnpm dlx instead of raising the clear unsupported-local-file error. Flip this to expect_err, and keep bare_file_in on the local-file path for already-existing files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cmd/run/local_file.rs` around lines 1266 - 1286, The bare-file resolution
test is asserting the old behavior where existing local files fall through to
PM-exec, which hides unsupported-file errors. Update the
`bare_file_ignores_missing_and_non_script_tokens` case to expect an error for
already-existing local files like `data.bin`, while keeping `bare_file_in`
coverage for the local-file path so it still distinguishes missing tokens from
real files.

379-396: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Plain /usr/bin/env shebangs are still being re-split, matey.

Arrr, without -S the shebang tail is one opaque argument to env, not a free buffet of argv tokens. This still makes #!/usr/bin/env deno run or #!/usr/bin/env python3 -O work on the non-exec path even though the executable form would fail, so the same script changes behaviour based on its exec bit.

🏴‍☠️ Minimal fix
-        let words = split_form.map_or_else(
-            || {
-                rest.split_whitespace()
-                    .map(ToOwned::to_owned)
-                    .collect::<Vec<_>>()
-            },
-            split_env_string,
-        );
+        let words = match split_form {
+            Some(split) => split_env_string(split),
+            None if rest.is_empty() => Vec::new(),
+            None => vec![rest.to_owned()],
+        };
Does `/usr/bin/env` split multiple shebang arguments without `-S`, and what do GNU coreutils docs say about `#!/usr/bin/env python3 -O` or `#!/usr/bin/env deno run` versus `#!/usr/bin/env -S ...`?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cmd/run/local_file.rs` around lines 379 - 396, The `/usr/bin/env` parsing
in the shebang handling still splits plain `env` tails with `split_whitespace`,
which incorrectly treats `#!/usr/bin/env deno run` and similar as multiple argv
tokens even without `-S`. Update the logic around the `split_form`/`words`
handling in `src/cmd/run/local_file.rs` so only `-S`/`--split-string` uses
`split_env_string`, while plain `env` preserves the entire remainder as a single
argument rather than re-splitting it. Keep the existing `split_form` detection
and adjust the fallback path to match `env`’s non-`-S` behavior consistently.

Source: MCP tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/cmd/run/local_file.rs`:
- Around line 1266-1286: The bare-file resolution test is asserting the old
behavior where existing local files fall through to PM-exec, which hides
unsupported-file errors. Update the
`bare_file_ignores_missing_and_non_script_tokens` case to expect an error for
already-existing local files like `data.bin`, while keeping `bare_file_in`
coverage for the local-file path so it still distinguishes missing tokens from
real files.
- Around line 379-396: The `/usr/bin/env` parsing in the shebang handling still
splits plain `env` tails with `split_whitespace`, which incorrectly treats
`#!/usr/bin/env deno run` and similar as multiple argv tokens even without `-S`.
Update the logic around the `split_form`/`words` handling in
`src/cmd/run/local_file.rs` so only `-S`/`--split-string` uses
`split_env_string`, while plain `env` preserves the entire remainder as a single
argument rather than re-splitting it. Keep the existing `split_form` detection
and adjust the fallback path to match `env`’s non-`-S` behavior consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: dcb1ebb6-b3de-4331-87bc-fe258a1bc33b

📥 Commits

Reviewing files that changed from the base of the PR and between d2c783e and c56055d.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • src/cmd/run/dispatch.rs
  • src/cmd/run/local_file.rs
📜 Review details
⏰ Context from checks skipped due to timeout. (3)
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: Analyze (rust)
  • GitHub Check: Analyze (actions)
🧰 Additional context used
📓 Path-based instructions (1)
**/CHANGELOG.md

📄 CodeRabbit inference engine (Custom checks)

**/CHANGELOG.md: If any source code files (excluding tests, docs, CI, markdown, or comments-only changes) are modified, CHANGELOG.md MUST also be modified in the same PR.
If a version bump is detected, CHANGELOG.md MUST contain a new section header matching the exact new version number in the format: '## [X.Y.Z] - YYYY-MM-DD'.
If NO version bump is detected, the changes in the PR MUST be added under the existing '## [Unreleased]' section in CHANGELOG.md. The entry MUST describe the changes (e.g., Added, Changed, Fixed, Removed).

Files:

  • CHANGELOG.md
🧠 Learnings (7)
📚 Learning: 2026-03-26T20:05:44.851Z
Learnt from: kjanat
Repo: kjanat/runner PR: 1
File: src/cmd/mod.rs:67-75
Timestamp: 2026-03-26T20:05:44.851Z
Learning: In Rust, `std::process::Command` does not provide getters for stdio configuration, so whether `Stdio::inherit()` (or other `Stdio::*` settings) was applied cannot be asserted via pure unit tests without spawning a process and inspecting OS-level fds. When reviewing Rust code, do not flag “missing unit test coverage” for stdio configuration on `std::process::Command` as a code issue—treat the explicit `Command::stdin/stdout/stderr` setter calls in the source as the meaningful guarantee.

Applied to files:

  • src/cmd/run/dispatch.rs
  • src/cmd/run/local_file.rs
📚 Learning: 2026-04-21T15:16:40.277Z
Learnt from: kjanat
Repo: kjanat/runner PR: 4
File: src/tool/just.rs:132-133
Timestamp: 2026-04-21T15:16:40.277Z
Learning: In Rust, if a method like `ExtractedTask::name() -> &str` returns a borrowed `&str` tied to `&self`, then using `sort_unstable_by_key(|t| t.name())` should be avoided because `by_key` requires the key to not borrow from the element being sorted (it will fail to compile due to the key’s lifetime). Do not recommend `sort_unstable_by_key` as a simplification in this situation. If you need an allocation-free and idiomatic comparison, use `sort_unstable_by(|a, b| a.name().cmp(b.name()))` instead. Using `.to_owned()` inside `by_key` is an alternative but allocates a `String` per element.

Applied to files:

  • src/cmd/run/dispatch.rs
  • src/cmd/run/local_file.rs
📚 Learning: 2026-05-04T23:28:17.947Z
Learnt from: kjanat
Repo: kjanat/runner PR: 5
File: src/lib.rs:222-223
Timestamp: 2026-05-04T23:28:17.947Z
Learning: When reviewing Rust `rustdoc` comments (`/// ...`), treat a trailing backslash (`\`) at the end of a comment line as valid CommonMark syntax for a hard line break (rendered as `<br>`). Do not flag such trailing backslashes as papercuts or recommend removing them unless there is clear evidence they are unintended (e.g., they are not in the `rustdoc` comment context or the surrounding formatting contradicts the intended CommonMark hard-break usage).

Applied to files:

  • src/cmd/run/dispatch.rs
  • src/cmd/run/local_file.rs
📚 Learning: 2026-06-01T17:42:48.461Z
Learnt from: kjanat
Repo: kjanat/runner PR: 34
File: Cargo.toml:61-61
Timestamp: 2026-06-01T17:42:48.461Z
Learning: In this repo, do not flag `actions_rs::log::GroupGuard` or `actions_rs::log::group_guard` as missing/non-existent when they are referenced in Rust code, because the `actions-rs` crate (dependency `actions-rs = "0.1"` / published v0.1.x) exports `pub struct GroupGuard` and `pub fn group_guard` from `actions_rs::log` (and also exports `actions_rs::env::is_github_actions`). This avoids false positives from outdated/incorrect web search results. If the repo does not depend on `actions-rs = "0.1"` in `Cargo.toml`, then normal missing-import/item checks can apply.

Applied to files:

  • src/cmd/run/dispatch.rs
  • src/cmd/run/local_file.rs
📚 Learning: 2026-06-11T18:52:28.233Z
Learnt from: kjanat
Repo: kjanat/runner PR: 45
File: src/lib.rs:659-661
Timestamp: 2026-06-11T18:52:28.233Z
Learning: In Rust, it’s acceptable to use `matches!(cli.command, Some(cli::Command::Doctor { .. }))` (or similar) when the scrutinee comes from a field accessed through a shared reference (e.g., `cli: &Cli`). If the match pattern uses `..` and binds zero fields (so only the enum discriminant is matched), Rust match ergonomics will avoid moving out of the referenced value. Reviewers should not flag such code as a compile-blocking move; confirm with `cargo check` rather than forcing changes like `.as_ref()` when compilation succeeds.

Applied to files:

  • src/cmd/run/dispatch.rs
  • src/cmd/run/local_file.rs
📚 Learning: 2026-05-14T15:35:39.922Z
Learnt from: kjanat
Repo: kjanat/runner PR: 27
File: src/cmd/run.rs:206-212
Timestamp: 2026-05-14T15:35:39.922Z
Learning: For Go projects using the canonical `cmd/` layout, when `runner` dispatches a local Go tool by its bare name (e.g., `some-cli`) under `PackageManager::Go`, resolve it relative to `ctx.root` by probing `ctx.root/cmd/<task_name>/`. If it exists, run the tool with the filesystem-path form: `go run ./cmd/<task_name>` (note the leading `./`). Do NOT generate `go run <task_name>` because bare names are treated as Go import paths, not local directories. While `go run <module>/cmd/<task_name>` can work, it’s unnecessary for local development—prefer `./cmd/<task_name>`.

Applied to files:

  • src/cmd/run/dispatch.rs
  • src/cmd/run/local_file.rs
📚 Learning: 2026-05-15T01:31:48.037Z
Learnt from: kjanat
Repo: kjanat/runner PR: 27
File: src/types.rs:584-585
Timestamp: 2026-05-15T01:31:48.037Z
Learning: In the `kjanat/runner` Rust codebase, remember that `just` treats its `justfile` name as officially case-insensitive and allows the hidden variant: `justfile`, `Justfile`, `JUSTFILE`, etc., and `.justfile`. `TaskSource::Justfile` detection should recognize these filename variants. Also note that `from_label` in `src/types.rs` is for parsing user-supplied qualifier prefixes (e.g. `justfile:build`), not for detecting the on-disk justfile. If you add/adjust qualifier label emission for `TaskSource::Justfile`, it’s not strictly required for backward compatibility because `TaskSource::label()` previously only emitted lowercase `"justfile"`, but adding additional casing/hidden variants as a defensive UX improvement is consistent with `just`’s official support.

Applied to files:

  • src/cmd/run/dispatch.rs
  • src/cmd/run/local_file.rs
🔍 Remote MCP GitHub Grep

Relevant context from related source code:

  • Deno’s task shell has an integration test where ./file.ts with #!/usr/bin/env -S deno run executes successfully, while #!/usr/bin/env deno run (no -S) fails with deno run: command not found. Its command layer also has explicit CommandPathResolutionError and FailedShebangError paths.
  • Deno’s CLI code documents parsing shebangs like #!/usr/bin/env -S deno run --allow-read with its own CLI parser, and its arg handling strips trailing \r from shebang arguments.
  • uv’s script code warns that directly executing a script may ignore inline metadata and recommends #!/usr/bin/env -S uv run --script; uv’s init tests preserve that shebang when generating scripts.
  • Bun’s bun run help/examples explicitly show local file execution (bun run ./index.js, bun run ./index.tsx) alongside package scripts.
  • Go’s go run docs say it can run a filesystem path or a list of .go source files, not only import paths.
🔇 Additional comments (1)
src/cmd/run/dispatch.rs (1)

158-171: LGTM!

Also applies to: 221-239

Review on #75 — two valid findings fixed, one skipped.

dispatch: move the bare local-file probe ahead of node-PM resolution (but
still after the `--runner` constraint hard error), so a local file runs
when PM resolution would hard-error for reasons unrelated to it (a strict
devEngines/packageManager mismatch, an incompatible `--pm`) — running
`main.ts` via its runtime never needed the package.json PM. The explicit
`--runner` constraint still hard-errors, never silently downgrading to a
coincidental file.

shebang: parse the `env -S` / `--split-string` form with a quote-aware
splitter instead of `split_whitespace`, so quoted arguments
(`-S deno run --allow-read="a b"`) stay a single token.

Skipped: routing an *unrecognized* existing bare file to an error instead
of PM-exec. That fallthrough is deliberate and pinned by
`bare_file_ignores_missing_and_non_script_tokens`; #69's protection already
errors on recognized-but-unrunnable files (e.g. a bare `.tsx` on node).
@kjanat kjanat force-pushed the fix/run-path-local-file-execution branch from c56055d to 300e297 Compare June 28, 2026 14:03
…hebang args

Review on #75 — two findings I previously skipped; on reflection both valid.

local file: once a bare token names an existing regular file, treat it as a
local file. An unrunnable one (unsupported type, no shebang and not executable,
a `.tsx` on node) now surfaces the clear `don't know how to run` error instead
of falling through to `bunx`/`npx <file>` and a registry 404 — the #69 bug, just
via the bare form rather than `./file`. A token that names nothing on disk (a
real package spec) still falls through to PM-exec. `bare_file_in` collapses to
`build_command?`, matching the explicit `./file` path.

shebang: the kernel hands the interpreter exactly one argument — the whole
remainder, internal spaces intact — never whitespace-split. Plain `env` and
direct interpreters now keep the tail as a single argument (`#!/usr/bin/env
python3 -O` resolves to program "python3 -O", as the kernel runs it); only
`env -S` / `--split-string` re-splits, via the quote-aware `split_env_string`.
kjanat added a commit that referenced this pull request Jun 28, 2026
The #75 branch forked before #71 and #73 merged, so its CHANGELOG was missing
their Unreleased entries. Three-way merged master's CHANGELOG.md (per-task
chain timing in Added, forced-PM source bias in Fixed) on top of the branch's
`run <path>` entry. The changelog now diverges from master by only this PR's
entry, so it merges cleanly when #75 lands.
@kjanat kjanat removed the cr:review Allow CodeRabbit review label Jun 28, 2026
@kjanat kjanat force-pushed the fix/run-path-local-file-execution branch from 4856069 to 59b5b7e Compare June 28, 2026 20:53
@kjanat kjanat merged commit 49d7a23 into master Jun 28, 2026
18 checks passed
@kjanat kjanat deleted the fix/run-path-local-file-execution branch June 28, 2026 21:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: cli Argument parsing, commands, and CLI UX bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(run): run <path> mis-routes local files to package-exec instead of executing them

2 participants