Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 228 additions & 0 deletions src/discover/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ static ENV_PREFIX: LazyLock<Regex> = LazyLock::new(|| {
static GIT_GLOBAL_OPT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?:(?:-C\s+\S+|-c\s+\S+|--git-dir(?:=\S+|\s+\S+)|--work-tree(?:=\S+|\s+\S+)|--no-pager|--no-optional-locks|--bare|--literal-pathspecs)\s+)+").unwrap()
});
// Strip pnpm global options that precede the subcommand so `pnpm -r install`,
// `pnpm --filter @app install`, `pnpm -w list` route to the same rules as their
// bare forms. Only a fixed, known set is stripped — never an unknown `-x`, so a
// non-install flag-first command can't be mis-rewritten into a filter with savings.
static PNPM_GLOBAL_OPT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?:(?:-r|--recursive|-w|--workspace-root|--filter(?:=\S+|\s+\S+)|-F(?:=\S+|\s+\S+))\s+)+").unwrap()
});
// Issue #1362: each capture expects a SINGLE file argument (`\S+$`). Multi-file
// invocations like `head -3 a b c` fail to match so the segment is passed through
// to the native `head`/`tail` binary — which already handles multi-file with
Expand Down Expand Up @@ -143,6 +150,21 @@ pub fn classify_command(cmd: &str) -> Classification {
// Strip golangci-lint global options before `run` so classify/rewrite stays
// aligned with the runtime wrapper behavior.
let cmd_normalized = strip_golangci_global_opts(&cmd_normalized);
// Strip pnpm global options (-r, --filter, -w) before the subcommand so
// `pnpm -r install` classifies like `pnpm install` — but only adopt the
// stripped form when it routes to the `rtk pnpm` rule itself. For the tool
// rules reachable via `pnpm exec`/`pnpm run` (`pnpm -r exec vitest`,
// `pnpm -r lint`, …) the rewrite matches the original flag-first text and
// never fires, so classifying the stripped form there would report a
// Supported saving the hook can't deliver — misleading `rtk discover` and
// `rtk session`, which count `Supported` as covered. See #3275.
let cmd_pnpm_stripped = strip_pnpm_global_opts(&cmd_normalized);
let cmd_normalized =
if cmd_pnpm_stripped != cmd_normalized && matches_pnpm_rule(&cmd_pnpm_stripped) {
cmd_pnpm_stripped
} else {
cmd_normalized
};
let cmd_clean = cmd_normalized.as_str();

// Exclude cat/head/tail with redirect operators — these are writes, not reads (#315)
Expand Down Expand Up @@ -366,6 +388,39 @@ fn strip_git_global_opts(cmd: &str) -> String {
format!("git {}", stripped.trim())
}

/// Strip pnpm global options before the subcommand (mirror of `strip_git_global_opts`).
/// `pnpm -r install` → `pnpm install`; `pnpm --filter @app list` → `pnpm list`.
/// Classification only — the rewrite re-emits the ORIGINAL command, so the stripped
/// flags are preserved (e.g. `pnpm -r install` → `rtk pnpm -r install`).
/// Returns the original string unchanged if not a pnpm command.
fn strip_pnpm_global_opts(cmd: &str) -> String {
// Require a single ASCII space after `pnpm` — the exact boundary the rewrite's
// `strip_word_prefix` enforces — so classify and rewrite can never diverge on a
// tab or other whitespace separator (that would resurrect the class of bug
// #3275 closes: Supported on one side, un-rewritable on the other). Extra
// spaces are still tolerated via `trim_start` (`pnpm -r install`), since
// `PNPM_GLOBAL_OPT` is `^`-anchored and a leading space would skip the strip.
if !cmd.starts_with("pnpm ") {
return cmd.to_string();
}
let after_pnpm = cmd[5..].trim_start(); // skip "pnpm ", then any extra spaces
let stripped = PNPM_GLOBAL_OPT.replace(after_pnpm, "");
format!("pnpm {}", stripped.trim())
}

/// True when `cmd` (already normalized) routes to the `rtk pnpm` rule rather than
/// a tool rule reachable through `pnpm exec`/`pnpm run`. Gates the pnpm
/// global-option strip in `classify_command`: adopting the stripped form for a
/// tool rule would diverge from the rewrite, which matches the original
/// flag-first text and never fires there. See #3275.
fn matches_pnpm_rule(cmd: &str) -> bool {
REGEX_SET
.matches(cmd)
.into_iter()
.next_back()
.is_some_and(|idx| RULES[idx].rtk_cmd == "rtk pnpm")
}

/// Strip golangci-lint global options before the `run` subcommand.
/// `golangci-lint --color never run ./...` → `golangci-lint run ./...`
/// Returns the original string unchanged if this is not a supported compact `run` invocation.
Expand Down Expand Up @@ -2911,6 +2966,179 @@ mod tests {
);
}

// --- pnpm global option stripping (-r / --filter / -w) ---

#[test]
fn test_rewrite_pnpm_recursive_install() {
assert_eq!(
rewrite_command_no_prefixes("pnpm -r install", &[]),
Some("rtk pnpm -r install".into())
);
}

#[test]
fn test_rewrite_pnpm_filter_install() {
assert_eq!(
rewrite_command_no_prefixes("pnpm --filter @app install", &[]),
Some("rtk pnpm --filter @app install".into())
);
}

#[test]
fn test_rewrite_pnpm_filter_short_install() {
assert_eq!(
rewrite_command_no_prefixes("pnpm -F @app install", &[]),
Some("rtk pnpm -F @app install".into())
);
}

#[test]
fn test_rewrite_pnpm_filter_eq_install() {
assert_eq!(
rewrite_command_no_prefixes("pnpm --filter=@app install", &[]),
Some("rtk pnpm --filter=@app install".into())
);
}

#[test]
fn test_rewrite_pnpm_workspace_root_install() {
assert_eq!(
rewrite_command_no_prefixes("pnpm -w install", &[]),
Some("rtk pnpm -w install".into())
);
}

#[test]
fn test_rewrite_pnpm_recursive_filter_combo() {
assert_eq!(
rewrite_command_no_prefixes("pnpm -r --filter @app list", &[]),
Some("rtk pnpm -r --filter @app list".into())
);
}

// No-regression: bare forms behave exactly as before.
#[test]
fn test_rewrite_pnpm_bare_install_unchanged() {
assert_eq!(
rewrite_command_no_prefixes("pnpm install", &[]),
Some("rtk pnpm install".into())
);
}

#[test]
fn test_rewrite_pnpm_run_build_unchanged() {
assert_eq!(
rewrite_command_no_prefixes("pnpm run build", &[]),
Some("rtk pnpm run build".into())
);
}

// Bare `pnpm build` is still NOT rewritten: it would only hit the passthrough
// (no output parser), so rewriting it would add false-positive surface for zero
// savings. Stripping global opts must not change this.
#[test]
fn test_rewrite_pnpm_bare_build_none() {
assert_eq!(rewrite_command_no_prefixes("pnpm build", &[]), None);
}

// False-positive guards.
#[test]
fn test_rewrite_pnpm_filter_no_subcommand_none() {
// A filter with no subcommand must not be rewritten.
assert_eq!(rewrite_command_no_prefixes("pnpm --filter @app", &[]), None);
}

#[test]
fn test_rewrite_pnpm_unknown_flag_not_stripped() {
// `-x` is not a known global opt → not stripped → no subcommand → None.
assert_eq!(rewrite_command_no_prefixes("pnpm -x build", &[]), None);
// Load-bearing case for the fixed-set design: `install` IS a routed
// subcommand, so if `-x` were stripped this would rewrite to
// `rtk pnpm -x install`, which reaches clap and dies. Only the fixed
// allowlist keeps it a safe passthrough (None).
assert_eq!(rewrite_command_no_prefixes("pnpm -x install", &[]), None);
}

#[test]
fn test_rewrite_pnpm_recursive_lint_safe_noop() {
// `pnpm lint` classifies as Supported, but the ORIGINAL `pnpm -r lint`
// matches no lint rewrite-prefix → safe no-op (never a malformed rewrite).
assert_eq!(rewrite_command_no_prefixes("pnpm -r lint", &[]), None);
}

#[test]
fn test_classify_pnpm_flag_first_tool_stays_unsupported() {
// #3275 blocker: the strip must not make a tool rule reachable via
// `pnpm exec`/`pnpm run` classify as Supported — its rewrite matches the
// original flag-first text and never fires, so a Supported verdict would
// advertise savings `rtk discover`/`rtk session` can never deliver. These
// must classify exactly as on develop: Unsupported(pnpm).
for cmd in [
"pnpm -r lint",
"pnpm -r exec eslint .",
"pnpm --filter @app exec vitest run",
"pnpm -F web exec playwright test",
"pnpm -r exec tsc --noEmit",
"pnpm -w exec next build",
] {
assert!(
matches!(classify_command(cmd), Classification::Unsupported { .. }),
"{cmd} must stay Unsupported (rewrite can't fire), got: {:?}",
classify_command(cmd)
);
}
}

#[test]
fn test_classify_pnpm_flag_first_install_still_supported() {
// The gate keeps everything the PR claims: flag-first forms that route to
// the `rtk pnpm` rule stay Supported.
for cmd in [
"pnpm -r install",
"pnpm --filter @app list",
"pnpm -w install",
"pnpm -r outdated",
] {
assert!(
matches!(
classify_command(cmd),
Classification::Supported {
rtk_equivalent: "rtk pnpm",
..
}
),
"{cmd} must classify as rtk pnpm, got: {:?}",
classify_command(cmd)
);
}
}

#[test]
fn test_rewrite_pnpm_extra_whitespace() {
// Extra spaces before the global flag must not skip the strip
// (`PNPM_GLOBAL_OPT` is `^`-anchored, so the slice is trimmed first).
assert_eq!(
rewrite_command_no_prefixes("pnpm -r install", &[]),
Some("rtk pnpm -r install".into())
);
}

#[test]
fn test_pnpm_tab_separator_no_classify_rewrite_divergence() {
// A non-space separator must NOT be stripped: the rewrite's
// `strip_word_prefix` only accepts an ASCII space, so classify has to
// agree and stay Unsupported. If the strip tolerated `\t` (or any other
// whitespace), classify would say Supported(rtk pnpm) while rewrite
// returned None — the exact classify/rewrite divergence #3275 closes.
let cmd = "pnpm\t-r install";
assert!(
matches!(classify_command(cmd), Classification::Unsupported { .. }),
"tab-separated pnpm must stay Unsupported, got: {:?}",
classify_command(cmd)
);
assert_eq!(rewrite_command_no_prefixes(cmd, &[]), None);
}

#[test]
fn test_rewrite_cargo_test() {
assert_eq!(
Expand Down
Loading
Loading