From 889c7ae9c1a92cc0c5d79689fea7b4fa4a52b070 Mon Sep 17 00:00:00 2001 From: Bart Waardenburg Date: Mon, 1 Jun 2026 23:07:33 +0200 Subject: [PATCH] fix(core): strip surrounding quotes from package-script file tokens Fixes #841 --- CHANGELOG.md | 1 + crates/core/src/scripts/mod.rs | 68 +++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5696a0f34..582f8ac04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Quoted globs in package.json scripts are now registered as entry points.** A test script using the idiomatic `node --test --import tsx 'src/**/*.test.ts'` form captured its argument as the literal `'src/**/*.test.ts'` with the surrounding single quotes intact, so the compiled entry pattern matched zero files and the test files (and their test-only helper modules) surfaced as false `unused-file` / `unused-export` findings. The script tokenizer now strips a matching pair of surrounding single or double quotes from each token, while leaving tokens with internal quotes (such as `can't`) unchanged. Unquoted globs continue to work as before. (Closes [#841](https://github.com/fallow-rs/fallow/issues/841).) - **Svelte markup `` as runtime HTML, not bundled SFC script modules. Fallow now keeps parsing the `src` metadata for span fidelity but no longer emits synthetic imports for Svelte `script src` references, so root-relative browser assets do not become false `unresolved-imports`. Vue external scripts keep their existing graph edges. Thanks [@codingthat](https://github.com/codingthat) for the report. (Closes [#835](https://github.com/fallow-rs/fallow/issues/835).) - **Pinia stores auto-imported by `@pinia/nuxt` are now tracked in the module graph.** Fallow now activates a Pinia plugin on `@pinia/nuxt`, scans direct store files in `stores/` and Nuxt 4 `app/stores/`, and maps value exports named `useStore` to the file that provides them. Pages that call `useUserStore()` without an import now keep `stores/user.ts` reachable and credit the matching export, while sibling helper exports can still report as unused. Nested stores and custom `storesDirs` remain out of scope for the default detector. (Closes [#740](https://github.com/fallow-rs/fallow/issues/740).) - **TanStack Router `routeFileIgnorePattern` warnings now call out JavaScript regex compatibility instead of treating every unsupported pattern as a user typo.** TanStack accepts JavaScript regular expressions, while fallow validates route ignore patterns with Rust's regex engine before matching route file segments. Patterns that use JavaScript-only syntax such as lookahead are still ignored during analysis so the run can continue, but the warning now names `routeFileIgnorePattern`, points at the source config file when known, and explains that the syntax is unsupported by fallow's Rust regex engine. (Refs [#513](https://github.com/fallow-rs/fallow/issues/513).) diff --git a/crates/core/src/scripts/mod.rs b/crates/core/src/scripts/mod.rs index 7f2415d85..7f57ee0c5 100644 --- a/crates/core/src/scripts/mod.rs +++ b/crates/core/src/scripts/mod.rs @@ -293,9 +293,27 @@ fn extract_args_for_binary( (file_args, config_args) } +/// Strip a matching pair of surrounding single or double quotes from a token. +/// +/// Only strips when the token both starts and ends with the same quote character. +/// A token with a single internal quote (e.g. `can't`) is returned unchanged. +fn strip_surrounding_quotes(token: &str) -> &str { + if token.len() >= 2 { + let first = token.as_bytes()[0]; + let last = token.as_bytes()[token.len() - 1]; + if (first == b'\'' || first == b'"') && first == last { + return &token[1..token.len() - 1]; + } + } + token +} + /// Parse a single command segment (after splitting on shell operators). fn parse_command_segment(segment: &str) -> Option { - let tokens: Vec<&str> = segment.split_whitespace().collect(); + let tokens: Vec<&str> = segment + .split_whitespace() + .map(strip_surrounding_quotes) + .collect(); if tokens.is_empty() { return None; } @@ -1327,6 +1345,54 @@ mod tests { assert!(!result.used_packages.contains("server")); } + #[test] + fn node_test_quoted_glob_strips_quotes() { + // Regression test for issue #841: quoted glob args kept their quotes, + // causing looks_like_file_path to reject them and the entry pattern to + // match zero files. + let cmds = parse_script("node --test --import tsx 'src/**/*.test.ts'"); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].binary, "node"); + // The surrounding quotes must be stripped from the glob. + assert!( + cmds[0].file_args.contains(&"src/**/*.test.ts".to_string()), + "expected unquoted glob in file_args, got: {:?}", + cmds[0].file_args + ); + assert!( + !cmds[0] + .file_args + .iter() + .any(|f| f.starts_with('\'') || f.ends_with('\'')), + "file_args must not contain surrounding single quotes" + ); + } + + #[test] + fn node_test_unquoted_glob_still_works() { + // Unquoted globs must continue to be extracted correctly. + let cmds = parse_script("node --test src/**/*.test.ts"); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].binary, "node"); + assert!(cmds[0].file_args.contains(&"src/**/*.test.ts".to_string())); + } + + #[test] + fn token_with_internal_single_quote_unchanged() { + // A token whose quote is internal (not surrounding) must not be mangled. + // Use a file arg that contains an internal apostrophe but is not shell-quoted. + // We exercise strip_surrounding_quotes directly via a known non-file-path + // context: confirm parse_script does not mangle such a token. + assert_eq!(super::strip_surrounding_quotes("can't"), "can't"); + assert_eq!(super::strip_surrounding_quotes("'quoted'"), "quoted"); + assert_eq!(super::strip_surrounding_quotes("\"quoted\""), "quoted"); + assert_eq!( + super::strip_surrounding_quotes("'mismatched\""), + "'mismatched\"" + ); + assert_eq!(super::strip_surrounding_quotes(""), ""); + } + mod proptests { use super::*; use proptest::prelude::*;