diff --git a/PARITY.md b/PARITY.md index bb4dbef6e..110df5892 100644 --- a/PARITY.md +++ b/PARITY.md @@ -145,7 +145,13 @@ argv order and resolve the whole set via the plural shared resolver (`resolve_configs_extension_specs` / `resolveConfigsExtensionSpecs`) — the Python fix landed in [#756](https://github.com/thekevinscott/dirsql/pull/756), the Node fix in [#758](https://github.com/thekevinscott/dirsql/pull/758) -(restoring parity). Python additionally feeds discovery-injected plugin +(restoring parity). Since +[#1078](https://github.com/thekevinscott/dirsql/issues/1078) the scan itself +is one core function, `dirsql::launcher::config_paths_from_argv` +(`#[doc(hidden)]`, not a public Rust API), reached through each binding +(`dirsql._dirsql.config_paths_from_argv` / the addon's `configPathsFromArgv`), +so the two launchers agree on flag spellings and the `./.dirsql.toml` default +by construction. Python additionally feeds discovery-injected plugin fragments through this path; Node has no plugin discovery (intentional drift, [#529](https://github.com/thekevinscott/dirsql/issues/529)), so its scan covers user-passed flags only. diff --git a/packages/python/changelog.d/2026-09-09-launcher-config-path-scan.md b/packages/python/changelog.d/2026-09-09-launcher-config-path-scan.md new file mode 100644 index 000000000..88689dc31 --- /dev/null +++ b/packages/python/changelog.d/2026-09-09-launcher-config-path-scan.md @@ -0,0 +1,3 @@ +**Changed** + +- **The launcher's config-path scan now runs in the shared core.** Before clap sees argv, the pip launcher still walks it for every `--config` / `-c` value (all five spellings) so a bare package-name `[[dirsql.extension]]` entry can be resolved through `importlib` — but the walk itself is now `dirsql::launcher::config_paths_from_argv`, reached through the private `dirsql._dirsql.config_paths_from_argv` binding; the Python copy is deleted. No user-observable change: same flags, same paths, same default of `./.dirsql.toml`. (#1078, #1072) diff --git a/packages/python/dirsql/_dirsql.pyi b/packages/python/dirsql/_dirsql.pyi index 41af49ca8..b0b7246de 100644 --- a/packages/python/dirsql/_dirsql.pyi +++ b/packages/python/dirsql/_dirsql.pyi @@ -77,3 +77,5 @@ class DirSQL: def query(self, sql: str) -> list[Row]: ... def _start_watcher(self) -> None: ... def _poll_events(self, timeout_ms: int) -> list[RowEvent]: ... + +def config_paths_from_argv(argv: list[str]) -> list[str]: ... diff --git a/packages/python/dirsql/cli/config_paths_from_argv.py b/packages/python/dirsql/cli/config_paths_from_argv.py deleted file mode 100644 index 74a4c09d1..000000000 --- a/packages/python/dirsql/cli/config_paths_from_argv.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Config-path extraction from the launcher's argv. - -Every config flag occurrence counts -- ``-c``/``--config`` are repeatable, -and plugin discovery injects fragments as additional ``-c`` flags -- so the -scan collects them all, in argv order. -""" - -from __future__ import annotations - - -def config_paths_from_argv(argv: list[str]) -> list[str]: - """Every config value in argv, in order (``--config X``, ``--config=X``, - ``-c X``, ``-c=X``, ``-cX``), or the default when none are given.""" - paths: list[str] = [] - i = 0 - while i < len(argv): - a = argv[i] - if a == "--config" or a == "-c": - # A bare trailing flag (no following value) yields "". - paths.append(next(iter(argv[i + 1 :]), "")) - i += 2 - continue - if a.startswith("--config="): - paths.append(a[len("--config=") :]) - elif a.startswith("-c"): - paths.append(a[len("-c") :].removeprefix("=")) - i += 1 - return paths or ["./.dirsql.toml"] diff --git a/packages/python/dirsql/cli/config_paths_from_argv_test.py b/packages/python/dirsql/cli/config_paths_from_argv_test.py deleted file mode 100644 index 6cc907652..000000000 --- a/packages/python/dirsql/cli/config_paths_from_argv_test.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Unit tests for `config_paths_from_argv`.""" - -from dirsql.cli.config_paths_from_argv import config_paths_from_argv - - -def describe_config_paths_from_argv(): - def it_reads_the_config_form(): - assert config_paths_from_argv(["--config", "/cfg/.dirsql.toml"]) == [ - "/cfg/.dirsql.toml" - ] - - def it_reads_the_config_equals_form(): - assert config_paths_from_argv(["--config=/c/.dirsql.toml"]) == [ - "/c/.dirsql.toml" - ] - - def it_reads_the_short_c_form(): - # Discovery injects fragments as `-c `; those must be collected - # exactly like `--config `. - assert config_paths_from_argv(["-c", "/frag/dirsql.toml"]) == [ - "/frag/dirsql.toml" - ] - - def it_reads_the_short_c_equals_form(): - assert config_paths_from_argv(["-c=/frag/dirsql.toml"]) == ["/frag/dirsql.toml"] - - def it_reads_the_short_c_attached_form(): - assert config_paths_from_argv(["-c/frag/dirsql.toml"]) == ["/frag/dirsql.toml"] - - def it_collects_every_config_flag_in_argv_order(): - argv = ["-c", "a.toml", "--config", "b.toml", "--config=c.toml", "-cd.toml"] - assert config_paths_from_argv(argv) == ["a.toml", "b.toml", "c.toml", "d.toml"] - - def it_reads_the_config_value_at_any_argv_position(): - assert config_paths_from_argv(["-v", "--config", "/x/y", "tail"]) == ["/x/y"] - - def it_collects_a_discovery_injected_fragment_not_the_default(): - # The user passed no config; discovery appended `--include-default -c - # `. The fragment -- not `./.dirsql.toml` -- is the result. - argv = ["query", "SELECT 1", "--include-default", "-c", "/frag/dirsql.toml"] - assert config_paths_from_argv(argv) == ["/frag/dirsql.toml"] - - def it_consumes_a_config_value_that_looks_like_a_flag(): - # The token after `--config` / `-c` is that flag's value; it is never - # re-parsed as another config flag. - assert config_paths_from_argv(["--config", "-cx.toml"]) == ["-cx.toml"] - - def it_matches_the_config_flag_by_value_not_identity_or_ordering(): - # A runtime-built "--config" (not the interned literal) must match, - # and flags sorting on either side of "--config" must not. - assert config_paths_from_argv(["".join(["--con", "fig"]), "/x/y"]) == ["/x/y"] - assert config_paths_from_argv(["--a", "val"]) == ["./.dirsql.toml"] - - def it_does_not_mistake_other_short_flags_for_config(): - assert config_paths_from_argv(["-v", "-x", "val"]) == ["./.dirsql.toml"] - - def it_defaults_to_dot_dirsql_toml_when_no_config_given(): - assert config_paths_from_argv(["--port", "9000"]) == ["./.dirsql.toml"] - - def it_defaults_to_dot_dirsql_toml_for_an_empty_argv(): - assert config_paths_from_argv([]) == ["./.dirsql.toml"] - - def it_treats_a_bare_trailing_config_as_an_empty_path(): - assert config_paths_from_argv(["--config"]) == [""] - - def it_treats_a_bare_trailing_short_c_as_an_empty_path(): - assert config_paths_from_argv(["-c"]) == [""] - - def it_keeps_a_bare_trailing_short_c_after_an_earlier_config(): - assert config_paths_from_argv(["--config", "a.toml", "-c"]) == ["a.toml", ""] diff --git a/packages/python/dirsql/cli/resolve_config_extensions.py b/packages/python/dirsql/cli/resolve_config_extensions.py index 72b6b4936..58826b220 100644 --- a/packages/python/dirsql/cli/resolve_config_extensions.py +++ b/packages/python/dirsql/cli/resolve_config_extensions.py @@ -15,8 +15,8 @@ from __future__ import annotations +from .._dirsql import config_paths_from_argv from ..resolve_configs_extension_specs import resolve_configs_extension_specs -from .config_paths_from_argv import config_paths_from_argv # Config extensions the binary dispatches to `dirsql interpret`; never # pre-resolved here (that path resolves via the handshake). diff --git a/packages/python/dirsql/cli/resolve_config_extensions_test.py b/packages/python/dirsql/cli/resolve_config_extensions_test.py index 5738f2c56..fd6581a6d 100644 --- a/packages/python/dirsql/cli/resolve_config_extensions_test.py +++ b/packages/python/dirsql/cli/resolve_config_extensions_test.py @@ -32,6 +32,9 @@ def it_imports_the_shared_resolver_from_the_plural_module(): == "dirsql.resolve_configs_extension_specs" ) + def it_reaches_the_argv_scan_through_the_core_binding(): + assert rce.config_paths_from_argv.__module__ == "dirsql._dirsql" + def it_passes_init_through_untouched(): argv = ["init", "--root", "."] with _patch([{"path": "R:pkg", "entrypoint": None}]) as (scan, resolver): diff --git a/packages/python/e2e-attestations/claude-1078-argv-config-paths-core.json b/packages/python/e2e-attestations/claude-1078-argv-config-paths-core.json new file mode 100644 index 000000000..f2160587b --- /dev/null +++ b/packages/python/e2e-attestations/claude-1078-argv-config-paths-core.json @@ -0,0 +1,7 @@ +{ + "command": "just test-e2e", + "ran_at": 1788979577, + "exit_code": 0, + "commit": "ae4f94c0b871cd766d45be67204c8a523a58c707", + "branch": "claude/1078-argv-config-paths-core" +} diff --git a/packages/python/src/lib.rs b/packages/python/src/lib.rs index 840eb90f6..7cc3ce819 100644 --- a/packages/python/src/lib.rs +++ b/packages/python/src/lib.rs @@ -462,6 +462,13 @@ mod python { py.detach(|| dirsql::cli::run_cli(full)) } + /// The config paths a launcher must inspect for package-name extensions; + /// see [`dirsql::launcher::config_paths_from_argv`]. + #[pyfunction] + fn config_paths_from_argv(argv: Vec) -> Vec { + dirsql::launcher::config_paths_from_argv(&argv) + } + #[pymodule] #[pyo3(name = "_dirsql")] fn py_dirsql_module(m: &Bound<'_, PyModule>) -> PyResult<()> { @@ -471,6 +478,7 @@ mod python { m.add_class::()?; m.add_class::()?; m.add_function(wrap_pyfunction!(run_cli, m)?)?; + m.add_function(wrap_pyfunction!(config_paths_from_argv, m)?)?; Ok(()) } @@ -553,6 +561,14 @@ mod python { assert_eq!(OnFileError("bad".to_string()).to_string(), "bad"); } + #[test] + fn config_paths_from_argv_forwards_to_the_core_scan() { + assert_eq!( + config_paths_from_argv(vec!["-c".into(), "a.toml".into()]), + ["a.toml"] + ); + } + #[test] fn int_overflow_message_names_the_value() { let m = int_overflow_message("9223372036854775808"); diff --git a/packages/rust/changelog.d/2026-09-09-launcher-config-path-scan.md b/packages/rust/changelog.d/2026-09-09-launcher-config-path-scan.md new file mode 100644 index 000000000..5501192dd --- /dev/null +++ b/packages/rust/changelog.d/2026-09-09-launcher-config-path-scan.md @@ -0,0 +1,3 @@ +**Added** + +- **The core now owns the argv scan that finds a launcher's config paths.** `dirsql::launcher::config_paths_from_argv` returns every `--config` / `-c` value an invocation names (all five spellings: `--config X`, `--config=X`, `-c X`, `-c=X`, `-cX`), or `./.dirsql.toml` when argv names none. The pip and npm launchers need those paths *before* the core parses argv, because a config's `[[dirsql.extension]]` entry may name an extension by bare package name and only the interpreter can resolve that — but locating the config files is language-neutral, so all three copies of the scan collapse into one. The module is `#[doc(hidden)]`: it exists for the bindings, not as a stable Rust API. (#1078, #1072) diff --git a/packages/rust/src/launcher.rs b/packages/rust/src/launcher.rs new file mode 100644 index 000000000..6a1a32385 --- /dev/null +++ b/packages/rust/src/launcher.rs @@ -0,0 +1,168 @@ +//! Argv scanning the language launchers share. +//! +//! The pip and npm launchers must know which config files an invocation names +//! *before* the core parses argv: a config's `[[dirsql.extension]]` entry may +//! name an extension by bare package name, and resolving that needs the +//! interpreter the launcher has and this crate does not. Everything about +//! locating those config paths is language-neutral, so it lives here and each +//! launcher reaches it through its binding rather than reimplementing it. + +/// The config a launcher inspects when argv names none. +const DEFAULT_CONFIG_PATH: &str = "./.dirsql.toml"; + +/// Every config value in `argv`, in order (`--config X`, `--config=X`, +/// `-c X`, `-c=X`, `-cX`), or [`DEFAULT_CONFIG_PATH`] when argv names none. +/// +/// The scan is deliberately looser than clap: it runs before argv is parsed, +/// only to find files to read extensions out of, so a bare trailing `-c` +/// yields an empty string rather than an error. A malformed invocation is +/// clap's to reject once the launcher hands argv over. +pub fn config_paths_from_argv(argv: &[String]) -> Vec { + let mut paths = Vec::new(); + let mut args = argv.iter(); + while let Some(arg) = args.next() { + if arg == "--config" || arg == "-c" { + paths.push(args.next().cloned().unwrap_or_default()); + } else if let Some(value) = arg.strip_prefix("--config=") { + paths.push(value.to_string()); + } else if let Some(value) = arg.strip_prefix("-c") { + paths.push(value.strip_prefix('=').unwrap_or(value).to_string()); + } + } + if paths.is_empty() { + paths.push(DEFAULT_CONFIG_PATH.to_string()); + } + paths +} + +#[cfg(test)] +mod tests { + use super::config_paths_from_argv; + + fn scan(argv: &[&str]) -> Vec { + config_paths_from_argv(&argv.iter().map(|a| (*a).to_string()).collect::>()) + } + + #[test] + fn reads_the_long_config_form() { + assert_eq!( + scan(&["--config", "/cfg/.dirsql.toml"]), + ["/cfg/.dirsql.toml"] + ); + } + + #[test] + fn reads_the_long_config_equals_form() { + assert_eq!(scan(&["--config=/c/.dirsql.toml"]), ["/c/.dirsql.toml"]); + } + + #[test] + fn reads_the_short_c_form() { + assert_eq!(scan(&["-c", "/frag/dirsql.toml"]), ["/frag/dirsql.toml"]); + } + + #[test] + fn reads_the_short_c_equals_form() { + assert_eq!(scan(&["-c=/frag/dirsql.toml"]), ["/frag/dirsql.toml"]); + } + + #[test] + fn reads_the_short_c_attached_form() { + assert_eq!(scan(&["-c/frag/dirsql.toml"]), ["/frag/dirsql.toml"]); + } + + #[test] + fn collects_every_config_flag_in_argv_order() { + assert_eq!( + scan(&[ + "-c", + "a.toml", + "--config", + "b.toml", + "--config=c.toml", + "-cd.toml" + ]), + ["a.toml", "b.toml", "c.toml", "d.toml"] + ); + } + + #[test] + fn reads_the_config_value_at_any_argv_position() { + assert_eq!(scan(&["-v", "--config", "/x/y", "tail"]), ["/x/y"]); + } + + #[test] + fn collects_a_discovery_injected_fragment_rather_than_the_default() { + assert_eq!( + scan(&[ + "query", + "SELECT 1", + "--include-default", + "-c", + "/frag/dirsql.toml" + ]), + ["/frag/dirsql.toml"] + ); + } + + #[test] + fn consumes_a_config_value_that_looks_like_a_flag() { + assert_eq!(scan(&["--config", "-cx.toml"]), ["-cx.toml"]); + } + + #[test] + fn consumes_a_config_value_that_is_itself_a_config_flag() { + assert_eq!(scan(&["-c", "-c", "x"]), ["-c"]); + } + + #[test] + fn does_not_mistake_other_short_flags_for_config() { + assert_eq!(scan(&["-v", "-x", "val"]), ["./.dirsql.toml"]); + } + + #[test] + fn does_not_mistake_other_long_flags_for_config() { + assert_eq!(scan(&["--a", "val"]), ["./.dirsql.toml"]); + } + + #[test] + fn does_not_mistake_a_config_prefixed_long_flag_for_config() { + assert_eq!(scan(&["--configuration", "val"]), ["./.dirsql.toml"]); + } + + #[test] + fn defaults_when_no_config_flag_is_given() { + assert_eq!(scan(&["--port", "9000"]), ["./.dirsql.toml"]); + } + + #[test] + fn defaults_for_an_empty_argv() { + assert_eq!(scan(&[]), ["./.dirsql.toml"]); + } + + #[test] + fn treats_a_bare_trailing_long_config_as_an_empty_path() { + assert_eq!(scan(&["--config"]), [""]); + } + + #[test] + fn treats_a_bare_trailing_short_c_as_an_empty_path() { + assert_eq!(scan(&["-c"]), [""]); + } + + #[test] + fn keeps_a_bare_trailing_short_c_after_an_earlier_config() { + assert_eq!(scan(&["--config", "a.toml", "-c"]), ["a.toml", ""]); + } + + #[test] + fn reads_an_empty_value_from_the_equals_forms() { + assert_eq!(scan(&["--config="]), [""]); + assert_eq!(scan(&["-c="]), [""]); + } + + #[test] + fn does_not_treat_a_double_dash_as_end_of_options() { + assert_eq!(scan(&["--", "-c", "a.toml"]), ["a.toml"]); + } +} diff --git a/packages/rust/src/lib.rs b/packages/rust/src/lib.rs index 6df900248..b0d8c52c0 100644 --- a/packages/rust/src/lib.rs +++ b/packages/rust/src/lib.rs @@ -20,6 +20,8 @@ pub mod functions; #[doc(hidden)] pub mod infer; #[doc(hidden)] +pub mod launcher; +#[doc(hidden)] pub mod matcher; #[doc(hidden)] pub mod parsed_cache; diff --git a/packages/ts/changelog.d/2026-09-09-launcher-config-path-scan.md b/packages/ts/changelog.d/2026-09-09-launcher-config-path-scan.md new file mode 100644 index 000000000..3a2450ad9 --- /dev/null +++ b/packages/ts/changelog.d/2026-09-09-launcher-config-path-scan.md @@ -0,0 +1,3 @@ +**Changed** + +- **The launcher's config-path scan now runs in the shared core.** Before clap sees argv, the npm launcher still walks it for every `--config` / `-c` value (all five spellings) so a bare package-name `[[dirsql.extension]]` entry can be resolved through `require.resolve` — but the walk itself is now `dirsql::launcher::config_paths_from_argv`, reached through the native addon's `configPathsFromArgv`; the TypeScript copy is deleted. No user-observable change: same flags, same paths, same default of `./.dirsql.toml`. (#1078, #1072) diff --git a/packages/ts/e2e-attestations/claude-1078-argv-config-paths-core.json b/packages/ts/e2e-attestations/claude-1078-argv-config-paths-core.json new file mode 100644 index 000000000..9c39ce4df --- /dev/null +++ b/packages/ts/e2e-attestations/claude-1078-argv-config-paths-core.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1788979666, + "exit_code": 0, + "commit": "8db47b0cdedb76fee4f120d7fe18bc39bbb405ab", + "branch": "claude/1078-argv-config-paths-core" +} diff --git a/packages/ts/napi/src/lib.rs b/packages/ts/napi/src/lib.rs index 64e9d6bf9..3b8b707bf 100644 --- a/packages/ts/napi/src/lib.rs +++ b/packages/ts/napi/src/lib.rs @@ -47,6 +47,13 @@ pub fn run_cli(argv: Vec) -> i32 { dirsql::cli::run_cli(full) } +/// The config paths the npm launcher must inspect for package-name +/// extensions; see [`dirsql::launcher::config_paths_from_argv`]. +#[napi(js_name = "configPathsFromArgv")] +pub fn config_paths_from_argv(argv: Vec) -> Vec { + dirsql::launcher::config_paths_from_argv(&argv) +} + /// A row-level event emitted by the file watcher. /// /// `table` is nullable because error events may occur before a file has @@ -1124,6 +1131,14 @@ mod tests { assert_eq!(out.error.as_deref(), Some("boom")); } + #[test] + fn config_paths_from_argv_forwards_to_the_core_scan() { + assert_eq!( + config_paths_from_argv(vec!["-c".into(), "a.toml".into()]), + ["a.toml"] + ); + } + #[test] fn to_napi_err_carries_message() { let err = to_napi_err("kaboom"); diff --git a/packages/ts/src/cli/config-paths-from-argv.test.ts b/packages/ts/src/cli/config-paths-from-argv.test.ts deleted file mode 100644 index 6e1e24896..000000000 --- a/packages/ts/src/cli/config-paths-from-argv.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { configPathsFromArgv } from "./config-paths-from-argv.js"; - -describe("configPathsFromArgv", () => { - it("reads the `--config X` form", () => { - expect(configPathsFromArgv(["--config", "/cfg/.dirsql.toml"])).toEqual([ - "/cfg/.dirsql.toml", - ]); - }); - - it("reads the `--config=X` form", () => { - expect(configPathsFromArgv(["--config=/c/.dirsql.toml"])).toEqual([ - "/c/.dirsql.toml", - ]); - }); - - it("reads the short `-c X` form", () => { - expect(configPathsFromArgv(["-c", "/frag/dirsql.toml"])).toEqual([ - "/frag/dirsql.toml", - ]); - }); - - it("reads the short `-c=X` form", () => { - expect(configPathsFromArgv(["-c=/frag/dirsql.toml"])).toEqual([ - "/frag/dirsql.toml", - ]); - }); - - it("reads the short attached `-cX` form", () => { - expect(configPathsFromArgv(["-c/frag/dirsql.toml"])).toEqual([ - "/frag/dirsql.toml", - ]); - }); - - it("collects every config flag in argv order", () => { - expect( - configPathsFromArgv([ - "-c", - "a.toml", - "--config", - "b.toml", - "--config=c.toml", - "-cd.toml", - ]), - ).toEqual(["a.toml", "b.toml", "c.toml", "d.toml"]); - }); - - it("reads the config value at any argv position", () => { - expect(configPathsFromArgv(["-v", "--config", "/x/y", "tail"])).toEqual([ - "/x/y", - ]); - }); - - it("consumes a config value that looks like a flag", () => { - // The token after `--config` / `-c` is that flag's value; it is never - // re-parsed as another config flag. - expect(configPathsFromArgv(["--config", "-cx.toml"])).toEqual(["-cx.toml"]); - }); - - it("does not mistake other short flags for config", () => { - expect(configPathsFromArgv(["-v", "-x", "val"])).toEqual([ - "./.dirsql.toml", - ]); - }); - - it("defaults to ./.dirsql.toml when no config flag is given", () => { - expect(configPathsFromArgv(["--port", "9000"])).toEqual(["./.dirsql.toml"]); - }); - - it("defaults to ./.dirsql.toml for an empty argv", () => { - expect(configPathsFromArgv([])).toEqual(["./.dirsql.toml"]); - }); - - it("treats a bare trailing --config as an empty path", () => { - expect(configPathsFromArgv(["--config"])).toEqual([""]); - }); - - it("treats a bare trailing -c as an empty path", () => { - expect(configPathsFromArgv(["-c"])).toEqual([""]); - }); - - it("keeps a bare trailing -c after an earlier config", () => { - expect(configPathsFromArgv(["--config", "a.toml", "-c"])).toEqual([ - "a.toml", - "", - ]); - }); -}); diff --git a/packages/ts/src/cli/config-paths-from-argv.ts b/packages/ts/src/cli/config-paths-from-argv.ts deleted file mode 100644 index 04da63910..000000000 --- a/packages/ts/src/cli/config-paths-from-argv.ts +++ /dev/null @@ -1,29 +0,0 @@ -// The `-c`/`--config` flag is repeatable, so the scan collects every -// occurrence, in argv order. - -/** - * Every config value in argv, in order (`--config X`, `--config=X`, `-c X`, - * `-c=X`, `-cX`), or the default when none are given. - */ -export function configPathsFromArgv(argv: string[]): string[] { - const paths: string[] = []; - let expectValue = false; - for (const a of argv) { - if (expectValue) { - paths.push(a); - expectValue = false; - } else if (a === "--config" || a === "-c") { - expectValue = true; - } else if (a.startsWith("--config=")) { - paths.push(a.slice("--config=".length)); - } else if (a.startsWith("-c")) { - const value = a.slice("-c".length); - paths.push(value.startsWith("=") ? value.slice("=".length) : value); - } - } - if (expectValue) { - // A bare trailing flag (no following value) yields "". - paths.push(""); - } - return paths.length > 0 ? paths : ["./.dirsql.toml"]; -} diff --git a/packages/ts/src/cli/resolve-config-extensions.test.ts b/packages/ts/src/cli/resolve-config-extensions.test.ts index 0375a6519..82c5ebe29 100644 --- a/packages/ts/src/cli/resolve-config-extensions.test.ts +++ b/packages/ts/src/cli/resolve-config-extensions.test.ts @@ -1,16 +1,11 @@ import { existsSync } from "node:fs"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getCore } from "../core.js"; import { resolveConfigsExtensionSpecs } from "../resolve-config-extensions.js"; -import { configPathsFromArgv } from "./config-paths-from-argv.js"; import { withResolvedExtensions } from "./resolve-config-extensions.js"; vi.mock("node:fs"); -vi.mock("./config-paths-from-argv.js", async () => ({ - ...(await vi.importActual( - "./config-paths-from-argv.js", - )), - configPathsFromArgv: vi.fn(), -})); +vi.mock("../core.js"); vi.mock("../resolve-config-extensions.js", async () => ({ ...(await vi.importActual( "../resolve-config-extensions.js", @@ -19,10 +14,13 @@ vi.mock("../resolve-config-extensions.js", async () => ({ })); describe("withResolvedExtensions", () => { + const configPathsFromArgv = vi.fn<(argv: string[]) => string[]>(); + beforeEach(() => { vi.clearAllMocks(); vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(configPathsFromArgv).mockReturnValue(["/x/.dirsql.toml"]); + vi.mocked(getCore).mockReturnValue({ configPathsFromArgv } as never); + configPathsFromArgv.mockReturnValue(["/x/.dirsql.toml"]); }); it("passes `init` through untouched without scanning or resolving", async () => { @@ -40,7 +38,7 @@ describe("withResolvedExtensions", () => { }); it("passes native configs through untouched (interpret resolves them)", async () => { - vi.mocked(configPathsFromArgv).mockReturnValue([ + configPathsFromArgv.mockReturnValue([ "cfg.py", "cfg.js", "cfg.mjs", @@ -53,7 +51,7 @@ describe("withResolvedExtensions", () => { }); it("drops native configs but resolves the TOML ones", async () => { - vi.mocked(configPathsFromArgv).mockReturnValue([ + configPathsFromArgv.mockReturnValue([ "cfg.py", "/frag/dirsql.toml", "other.cjs", @@ -67,7 +65,7 @@ describe("withResolvedExtensions", () => { it("passes through without loading the parser when no config file exists", async () => { vi.mocked(existsSync).mockReturnValue(false); - vi.mocked(configPathsFromArgv).mockReturnValue(["/gone/.dirsql.toml"]); + configPathsFromArgv.mockReturnValue(["/gone/.dirsql.toml"]); const argv = ["--config", "/gone/.dirsql.toml"]; expect(await withResolvedExtensions(argv)).toBe(argv); expect(existsSync).toHaveBeenCalledWith("/gone/.dirsql.toml"); @@ -78,10 +76,7 @@ describe("withResolvedExtensions", () => { // The resolver skips missing configs itself; the existsSync guard only // keeps the TOML parser off the launch path when NO config exists. vi.mocked(existsSync).mockImplementation((p) => p === "/b/.dirsql.toml"); - vi.mocked(configPathsFromArgv).mockReturnValue([ - "/a/.dirsql.toml", - "/b/.dirsql.toml", - ]); + configPathsFromArgv.mockReturnValue(["/a/.dirsql.toml", "/b/.dirsql.toml"]); vi.mocked(resolveConfigsExtensionSpecs).mockReturnValue(null); await withResolvedExtensions([ "-c", diff --git a/packages/ts/src/cli/resolve-config-extensions.ts b/packages/ts/src/cli/resolve-config-extensions.ts index e41c32aba..631ce7e20 100644 --- a/packages/ts/src/cli/resolve-config-extensions.ts +++ b/packages/ts/src/cli/resolve-config-extensions.ts @@ -12,7 +12,7 @@ // resolved paths. import { existsSync } from "node:fs"; -import { configPathsFromArgv } from "./config-paths-from-argv.js"; +import { getCore } from "../core.js"; // Config extensions the binary dispatches to `dirsql interpret`; never // pre-resolved here (that path resolves via the handshake). @@ -30,9 +30,9 @@ export async function withResolvedExtensions( if (argv[0] === "init") { return argv; } - const configPaths = configPathsFromArgv(argv).filter( - (p) => !NATIVE_CONFIG_SUFFIXES.some((s) => p.endsWith(s)), - ); + const configPaths = getCore() + .configPathsFromArgv(argv) + .filter((p) => !NATIVE_CONFIG_SUFFIXES.some((s) => p.endsWith(s))); // The shared resolver pulls in smol-toml, which only a TOML config on disk // can need. Guarding on the same `existsSync` the resolver itself starts // with keeps the parser off the common launch path entirely (#720); the diff --git a/packages/ts/src/core.test.ts b/packages/ts/src/core.test.ts index fd36e32b7..06cba3f88 100644 --- a/packages/ts/src/core.test.ts +++ b/packages/ts/src/core.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { + type CoreModule, type NativeDirSQL, type NativeDirSQLConstructor, getCore, @@ -21,6 +22,24 @@ describe("getCore", () => { }); }); +describe("CoreModule contract", () => { + it("carries the core's argv config-path scan beside the DirSQL class", () => { + const configPathsFromArgv = vi.fn((argv: string[]) => argv); + // Typed against the real interface, not cast: dropping the scan from + // `CoreModule` would make this literal fail to compile, which pins the + // member the npm launcher reaches for before the core parses argv. + const core: CoreModule = { + DirSQL: {} as NativeDirSQLConstructor, + configPathsFromArgv, + }; + expect(core.configPathsFromArgv(["-c", "a.toml"])).toEqual([ + "-c", + "a.toml", + ]); + expect(configPathsFromArgv).toHaveBeenCalledWith(["-c", "a.toml"]); + }); +}); + describe("NativeDirSQLConstructor contract", () => { it("accepts extensions, suppressConfigExtensions, and noIgnore as openAsync's trailing arguments (#230 / #313 / #746)", () => { const openAsync = vi.fn().mockResolvedValue({}); diff --git a/packages/ts/src/core.ts b/packages/ts/src/core.ts index c2b711352..a163d9e21 100644 --- a/packages/ts/src/core.ts +++ b/packages/ts/src/core.ts @@ -34,6 +34,7 @@ export interface NativeDirSQLConstructor { // native binary (`dirsql.node`); tests may substitute a fake. export interface CoreModule { DirSQL: NativeDirSQLConstructor; + configPathsFromArgv(argv: string[]): string[]; } // Unit tests `vi.mock("./core.js")` to fake `getCore` directly, so