From 64178a260a129d93e15bd4e83f58551e634e3ecb Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:52:11 -0400 Subject: [PATCH 1/5] =?UTF-8?q?test(core):=20red=20=E2=80=94=20launcher=20?= =?UTF-8?q?argv=20config-path=20scan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds dirsql::launcher::config_paths_from_argv with the 20 cases the three launcher copies of this scan collectively cover, against a stub that always returns the default config path. 15 of the 20 fail. Refs #1078 --- .../2026-09-09-launcher-config-path-scan.md | 3 + packages/rust/src/launcher.rs | 154 ++++++++++++++++++ packages/rust/src/lib.rs | 2 + 3 files changed, 159 insertions(+) create mode 100644 packages/rust/changelog.d/2026-09-09-launcher-config-path-scan.md create mode 100644 packages/rust/src/launcher.rs 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..b2e161121 --- /dev/null +++ b/packages/rust/src/launcher.rs @@ -0,0 +1,154 @@ +//! 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 { + vec![DEFAULT_CONFIG_PATH.to_string()] +} + +#[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; From ae4f94c0b871cd766d45be67204c8a523a58c707 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:45:42 -0400 Subject: [PATCH 2/5] core: implement the launcher config-path scan and route both launchers through it `dirsql::launcher::config_paths_from_argv` now walks argv for every `--config` / `-c` value (all five spellings) and falls back to `./.dirsql.toml`, turning the 20 red inline tests green. Both bindings export it: `dirsql._dirsql.config_paths_from_argv` (PyO3) and the napi addon's `configPathsFromArgv`. The pip and npm launchers call through the binding, and the per-language copies of the scan are deleted along with their unit tests. Launcher unit tests keep mocking the scan at the same seam, now the binding. PARITY.md records that the scan is shared by construction; changelog fragments for python and ts describe the no-observable-change move. Refs #1078, #1072 --- PARITY.md | 8 +- .../2026-09-09-launcher-config-path-scan.md | 3 + packages/python/dirsql/_dirsql.pyi | 2 + .../dirsql/cli/config_paths_from_argv.py | 28 ------ .../dirsql/cli/config_paths_from_argv_test.py | 70 --------------- .../dirsql/cli/resolve_config_extensions.py | 2 +- packages/python/src/lib.rs | 16 ++++ packages/rust/src/launcher.rs | 18 +++- .../2026-09-09-launcher-config-path-scan.md | 3 + packages/ts/napi/src/lib.rs | 15 ++++ .../ts/src/cli/config-paths-from-argv.test.ts | 88 ------------------- packages/ts/src/cli/config-paths-from-argv.ts | 29 ------ .../src/cli/resolve-config-extensions.test.ts | 22 +++-- .../ts/src/cli/resolve-config-extensions.ts | 6 +- packages/ts/src/core.ts | 1 + 15 files changed, 78 insertions(+), 233 deletions(-) create mode 100644 packages/python/changelog.d/2026-09-09-launcher-config-path-scan.md delete mode 100644 packages/python/dirsql/cli/config_paths_from_argv.py delete mode 100644 packages/python/dirsql/cli/config_paths_from_argv_test.py create mode 100644 packages/ts/changelog.d/2026-09-09-launcher-config-path-scan.md delete mode 100644 packages/ts/src/cli/config-paths-from-argv.test.ts delete mode 100644 packages/ts/src/cli/config-paths-from-argv.ts 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/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/src/launcher.rs b/packages/rust/src/launcher.rs index b2e161121..6a1a32385 100644 --- a/packages/rust/src/launcher.rs +++ b/packages/rust/src/launcher.rs @@ -17,8 +17,22 @@ const DEFAULT_CONFIG_PATH: &str = "./.dirsql.toml"; /// 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 { - vec![DEFAULT_CONFIG_PATH.to_string()] +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)] 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/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..f4af16d43 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,7 +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([ + configPathsFromArgv.mockReturnValue([ "/a/.dirsql.toml", "/b/.dirsql.toml", ]); diff --git a/packages/ts/src/cli/resolve-config-extensions.ts b/packages/ts/src/cli/resolve-config-extensions.ts index e41c32aba..87a9f7cd2 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,7 +30,9 @@ export async function withResolvedExtensions( if (argv[0] === "init") { return argv; } - const configPaths = configPathsFromArgv(argv).filter( + 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 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 From 8db47b0cdedb76fee4f120d7fe18bc39bbb405ab Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:46:17 -0400 Subject: [PATCH 3/5] e2e attestation for claude/1078-argv-config-paths-core --- .../claude-1078-argv-config-paths-core.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/python/e2e-attestations/claude-1078-argv-config-paths-core.json 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" +} From 04f5cca8881129924dbaf0746927e5374aa818e2 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:47:46 -0400 Subject: [PATCH 4/5] e2e attestation for claude/1078-argv-config-paths-core --- .../claude-1078-argv-config-paths-core.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/ts/e2e-attestations/claude-1078-argv-config-paths-core.json 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" +} From 1e17c94d180fb6dc69ebc1decf0732201137aa55 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:52:36 -0400 Subject: [PATCH 5/5] test: pin the launcher's binding seam in the colocated tests The launcher sources changed their collaborator from a sibling module to the core binding, so the colocated tests now assert that seam: the Python launcher's `config_paths_from_argv` is the `dirsql._dirsql` builtin, and `CoreModule` carries `configPathsFromArgv` as a typed member. Also biome-formats the two TS files the rewiring touched. --- .../cli/resolve_config_extensions_test.py | 3 +++ .../src/cli/resolve-config-extensions.test.ts | 5 +---- .../ts/src/cli/resolve-config-extensions.ts | 4 +--- packages/ts/src/core.test.ts | 19 +++++++++++++++++++ 4 files changed, 24 insertions(+), 7 deletions(-) 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/ts/src/cli/resolve-config-extensions.test.ts b/packages/ts/src/cli/resolve-config-extensions.test.ts index f4af16d43..82c5ebe29 100644 --- a/packages/ts/src/cli/resolve-config-extensions.test.ts +++ b/packages/ts/src/cli/resolve-config-extensions.test.ts @@ -76,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"); - 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 87a9f7cd2..631ce7e20 100644 --- a/packages/ts/src/cli/resolve-config-extensions.ts +++ b/packages/ts/src/cli/resolve-config-extensions.ts @@ -32,9 +32,7 @@ export async function withResolvedExtensions( } const configPaths = getCore() .configPathsFromArgv(argv) - .filter( - (p) => !NATIVE_CONFIG_SUFFIXES.some((s) => p.endsWith(s)), - ); + .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({});