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
4 changes: 4 additions & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,10 @@ So `glob = "projects/*/*.jsonl"` in `~/.claude/.dirsql.toml` indexes
writes `glob = "~/.claude/projects/*/*.jsonl"`. A table's `path` and `dir`
columns, `{root}`, and `ignore` are all relative to its anchor.

A table whose glob matches no files prints
`dirsql: table 'sessions': glob 'projects/*/*.jsonl' matched no files under <anchor>`
to stderr; the exit code is unchanged. Path-tables and programmatic tables do not warn.

### Batch `ddl`

`ddl` is handed to SQLite whole, so a table declaration is not limited to one
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Added**

- **A config `[[table]]` whose glob matches no files now prints a warning to stderr.** `dirsql: table 'sessions': glob 'projects/*/*.jsonl' matched no files under /home/u/.claude` names the table, the glob and the directory it was matched under, so a glob that silently indexes nothing is visible. The exit code is unchanged, and path-tables and programmatic tables do not warn. (#1232)
7 changes: 7 additions & 0 deletions packages/python/e2e-attestations/claude-1232-zero-match.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "uv run python -m pytest tests/e2e/fanout_test.py tests/e2e/capture_collision_test.py tests/e2e/declared_table_name_test.py tests/e2e/extension_package_test.py tests/e2e/plugin_discovery_test.py tests/e2e/use_case_notebook_test.py tests/e2e/on_file_query_test.py tests/e2e/use_case_transcripts_test.py tests/e2e/plugin_extension_discovery_test.py tests/e2e/use_case_join_test.py tests/e2e/use_case_dossier_test.py tests/e2e/sigint_interrupt_test.py tests/e2e/query_contract_test.py -x -q",
"ran_at": 1791306078,
"exit_code": 0,
"commit": "8b587de39902adf33e4ebaa957a8fd0005875029",
"branch": "claude/1232-zero-match"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
### Empty config tables warn on stderr (#1232)

#### Summary

A config `[[table]]` whose glob matches no files writes one line to stderr at startup. Nothing else changes: the query still runs and the exit code is the same.

#### Required changes

_None._ A script that treats any stderr output as failure should fix the glob or drop the table.

#### Deprecations removed

_None._

#### Behavior changes without code changes

- stderr gains `dirsql: table '<name>': glob '<glob>' matched no files under <anchor>` for each config table that matched nothing.

#### Verification

```bash
mkdir -p /tmp/cfg /tmp/elsewhere
printf '[[table]]\nname = "t"\nddl = "CREATE TABLE t (n TEXT)"\nglob = "nothing/*.jsonl"\non-file = "printf []"\n' > /tmp/cfg/.dirsql.toml
cd /tmp/elsewhere && dirsql query "SELECT COUNT(*) AS n FROM t" -c /tmp/cfg/.dirsql.toml # stderr: dirsql: table 't': glob 'nothing/*.jsonl' matched no files under /tmp/cfg
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Added**

- **A config `[[table]]` whose glob matches no files now prints a warning to stderr.** `dirsql: table 'sessions': glob 'projects/*/*.jsonl' matched no files under /home/u/.claude` names the table, the glob and the directory it was matched under, so a glob that silently indexes nothing is visible. The exit code is unchanged, and path-tables and programmatic tables do not warn. (#1232)
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
### Empty config tables warn on stderr (#1232)

#### Summary

A config `[[table]]` whose glob matches no files writes one line to stderr at startup. Nothing else changes: the query still runs and the exit code is the same.

#### Required changes

_None._ A script that treats any stderr output as failure should fix the glob or drop the table.

#### Deprecations removed

_None._

#### Behavior changes without code changes

- stderr gains `dirsql: table '<name>': glob '<glob>' matched no files under <anchor>` for each config table that matched nothing.

#### Verification

```bash
mkdir -p /tmp/cfg /tmp/elsewhere
printf '[[table]]\nname = "t"\nddl = "CREATE TABLE t (n TEXT)"\nglob = "nothing/*.jsonl"\non-file = "printf []"\n' > /tmp/cfg/.dirsql.toml
cd /tmp/elsewhere && dirsql query "SELECT COUNT(*) AS n FROM t" -c /tmp/cfg/.dirsql.toml # stderr: dirsql: table 't': glob 'nothing/*.jsonl' matched no files under /tmp/cfg
```
61 changes: 60 additions & 1 deletion packages/rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ pub struct Table {
hook: Hook,
anchor: Option<PathBuf>,
ignore: Vec<String>,
warn_when_empty: bool,
}

impl Table {
Expand All @@ -298,6 +299,11 @@ impl Table {
&self.ignore
}

pub(crate) fn warning_when_empty(mut self) -> Self {
self.warn_when_empty = true;
self
}

pub(crate) fn scoped_ignore(mut self, ignore: Vec<String>) -> Self {
self.ignore = ignore;
self
Expand Down Expand Up @@ -354,6 +360,7 @@ impl Table {
strict: false,
anchor: None,
ignore: Vec::new(),
warn_when_empty: false,
}
}

Expand Down Expand Up @@ -400,6 +407,7 @@ impl Table {
strict: false,
anchor: None,
ignore: Vec::new(),
warn_when_empty: false,
}
}
}
Expand Down Expand Up @@ -1128,6 +1136,9 @@ impl DirSQL {
let mut progress = Progress::scanning();
let (scanned, seen) = scan_groups(&groups, scan, &mut progress);
progress.finish(seen);
for warning in empty_table_warnings(&tables, &anchors, &scanned) {
eprintln!("{warning}");
}
scanned
};

Expand Down Expand Up @@ -2019,6 +2030,28 @@ fn compile_groups(
Ok((groups, anchors, names))
}

/// One stderr line per config table that matched no file in the scan, naming
/// the table, its glob and the directory the glob was matched under.
fn empty_table_warnings(
tables: &[Table],
anchors: &TableAnchors,
scanned: &[(PathBuf, String)],
) -> Vec<String> {
tables
.iter()
.filter(|table| table.warn_when_empty)
.filter(|table| !scanned.iter().any(|(_, name)| *name == table.name))
.map(|table| {
format!(
"dirsql: table '{}': glob '{}' matched no files under {}",
table.name,
table.glob,
anchors.of(&table.name).display()
)
})
.collect()
}

/// Open (or create) the persistent SQLite cache and read its meta. If the
/// meta is missing or incompatible with the current build, the resulting
/// [`PersistContext`] carries an empty file index and asks for a sweep, so
Expand Down Expand Up @@ -2423,7 +2456,8 @@ fn build_tables_from_config(cfg: &config::Config, config_dir: &Path) -> Result<V
},
)
.anchored(anchor)
.scoped_ignore(cfg.ignore.clone());
.scoped_ignore(cfg.ignore.clone())
.warning_when_empty();

if table_cfg.strict == Some(true) {
table.strict = true;
Expand Down Expand Up @@ -5451,6 +5485,31 @@ mod internal_tests {
assert!(matches!(err, DirSqlError::DuplicateTable(ref name) if name == "a"));
}

#[test]
fn an_empty_warning_table_is_named_with_its_glob_and_anchor() {
let tables = vec![anchored_table("a", "*.txt", "/one").warning_when_empty()];
let anchors = compile_groups(&tables, &[], Path::new("/idx")).unwrap().1;
assert_eq!(
empty_table_warnings(&tables, &anchors, &[]),
vec!["dirsql: table 'a': glob '*.txt' matched no files under /one".to_string()]
);
}

#[test]
fn a_table_with_a_matched_file_does_not_warn() {
let tables = vec![anchored_table("a", "*.txt", "/one").warning_when_empty()];
let anchors = compile_groups(&tables, &[], Path::new("/idx")).unwrap().1;
let scanned = vec![(PathBuf::from("/one/x.txt"), "a".to_string())];
assert!(empty_table_warnings(&tables, &anchors, &scanned).is_empty());
}

#[test]
fn a_programmatic_empty_table_does_not_warn() {
let tables = vec![anchored_table("a", "*.txt", "/one")];
let anchors = compile_groups(&tables, &[], Path::new("/idx")).unwrap().1;
assert!(empty_table_warnings(&tables, &anchors, &[]).is_empty());
}

#[test]
fn anchors_of_an_unknown_table_is_the_index_root() {
let anchors = TableAnchors::at_root(Path::new("/idx"));
Expand Down
71 changes: 71 additions & 0 deletions packages/rust/tests/cli_zero_match_warning.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//! A config `[[table]]` whose glob matches no files warns on stderr, naming the
//! table, the glob and the anchor. The exit code is unchanged.
//!
//! Gated behind `--features cli` like the sibling CLI e2e tests.
#![cfg(feature = "cli")]

use std::fs;
use std::path::Path;

use assert_cmd::prelude::*;
use tempfile::TempDir;

fn write_config(dir: &Path, glob: &str) -> std::path::PathBuf {
let path = dir.join(".dirsql.toml");
fs::write(
&path,
format!(
"[[table]]\nname = \"sessions\"\nddl = \"CREATE TABLE sessions (n TEXT)\"\nglob = \"{glob}\"\non-file = \"printf []\"\n"
),
)
.unwrap();
path
}

fn query(cwd: &Path, config: &Path) -> std::process::Output {
std::process::Command::cargo_bin("dirsql")
.expect("`dirsql` binary must be built by `cargo test` with --features cli")
.arg("query")
.arg("SELECT COUNT(*) AS n FROM sessions")
.arg("-c")
.arg(config)
.current_dir(cwd)
.output()
.unwrap()
}

#[test]
fn an_empty_config_table_warns_on_stderr_and_still_exits_zero() {
let cfg_dir = TempDir::new().unwrap();
let cfg_dir_path = fs::canonicalize(cfg_dir.path()).unwrap();
let config = write_config(&cfg_dir_path, "projects/*/*.jsonl");
let elsewhere = TempDir::new().unwrap();

let out = query(elsewhere.path(), &config);

assert!(out.status.success(), "{out:?}");
let stderr = String::from_utf8_lossy(&out.stderr);
let expected = format!(
"dirsql: table 'sessions': glob 'projects/*/*.jsonl' matched no files under {}",
cfg_dir_path.display()
);
assert!(
stderr.lines().any(|line| line == expected),
"expected {expected:?} in stderr, got {stderr:?}"
);
}

#[test]
fn a_config_table_with_matches_does_not_warn() {
let cfg_dir = TempDir::new().unwrap();
fs::create_dir_all(cfg_dir.path().join("projects/p")).unwrap();
fs::write(cfg_dir.path().join("projects/p/a.jsonl"), "{}").unwrap();
let config = write_config(cfg_dir.path(), "projects/*/*.jsonl");
let elsewhere = TempDir::new().unwrap();

let out = query(elsewhere.path(), &config);

assert!(out.status.success(), "{out:?}");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!stderr.contains("matched no files"), "got {stderr:?}");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Added**

- **A config `[[table]]` whose glob matches no files now prints a warning to stderr.** `dirsql: table 'sessions': glob 'projects/*/*.jsonl' matched no files under /home/u/.claude` names the table, the glob and the directory it was matched under, so a glob that silently indexes nothing is visible. The exit code is unchanged, and path-tables and programmatic tables do not warn. (#1232)
7 changes: 7 additions & 0 deletions packages/ts/e2e-attestations/claude-1232-zero-match.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "pnpm test:e2e",
"ran_at": 1791306118,
"exit_code": 0,
"commit": "055602415dffd51c3aa8145b39f5d9b0e8988590",
"branch": "claude/1232-zero-match"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
### Empty config tables warn on stderr (#1232)

#### Summary

A config `[[table]]` whose glob matches no files writes one line to stderr at startup. Nothing else changes: the query still runs and the exit code is the same.

#### Required changes

_None._ A script that treats any stderr output as failure should fix the glob or drop the table.

#### Deprecations removed

_None._

#### Behavior changes without code changes

- stderr gains `dirsql: table '<name>': glob '<glob>' matched no files under <anchor>` for each config table that matched nothing.

#### Verification

```bash
mkdir -p /tmp/cfg /tmp/elsewhere
printf '[[table]]\nname = "t"\nddl = "CREATE TABLE t (n TEXT)"\nglob = "nothing/*.jsonl"\non-file = "printf []"\n' > /tmp/cfg/.dirsql.toml
cd /tmp/elsewhere && dirsql query "SELECT COUNT(*) AS n FROM t" -c /tmp/cfg/.dirsql.toml # stderr: dirsql: table 't': glob 'nothing/*.jsonl' matched no files under /tmp/cfg
```
Loading