iriq 0.30.2 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 598c04e3c1777787ae9e5d1be98e2bc68d441e2020ebe7743bdf8075b20fdaec
4
- data.tar.gz: 396ad6b0b0acffb76b7bc2b4e31792b02ac65749c6ac769fd70a47ce5d806496
3
+ metadata.gz: 4290f75faee81b1292a3404fd01221299490731378ace043999e6ab7e7a0dc81
4
+ data.tar.gz: 0b1b074eb746ec2b50a790fb38da54c0715a420ec752929f3eb2af85bed50670
5
5
  SHA512:
6
- metadata.gz: 16637ff46f4648a2cfc14404ed074d3cedc6fc08cabf0c46a6fa7a39553b8b78020907fde1d9024005cec3a4f2cdb4cb3e999802ec866a942c20c15be6c7af34
7
- data.tar.gz: 458aa6deba73a571a07801bb3df445a4d08500a55c3d1d8c50b11df0811dbc785270fd1ef908ebbb58c6f53dfb1ecfc7013fe137e216e88bca81c9bc56d21fa4
6
+ metadata.gz: 81feb4d4b7c8fb0cd9f7e68dbec17fdc787642a6829f2f479849e458c14e5fa9812c251df08f57658b6cb338fcb112d8ec16393efb7aa4e4364e07b2dcf19ae1
7
+ data.tar.gz: 8979d705a2dd2d1db59c4b5fc794ba9307f061c2fbb7b3968c1585c8d633a7871d306c1ec372ad3dc15e11f1ec30c65a7f7833a6955dae8a3e11ca3f3a06d925
data/CHANGELOG.md CHANGED
@@ -1,3 +1,35 @@
1
+ ### 0.33.0 (2026-07-07)
2
+ - **New: end-to-end URL calibration corpus** — 160 messy real-world-shaped inputs (tokens, i18n, encoding damage, legacy endpoints, non-http schemes, garbage) with adjudicated expected templates at `spec/fixtures/calibration/urls.json`, generated by `script/build_url_calibration.rb`, asserted by both runtimes, and CI-gated for staleness. Building it caught the three fixes below.
3
+ - **Bugfix: opaque non-urn schemes were rewritten to `urn:` on normalize/canonical** — `mailto:support@foo.com` came back as `urn:support@foo.com`; same for `tel:`, `sms:`, `data:`, `blob:`, `magnet:`. The scheme is now preserved (both runtimes; cluster keys too).
4
+ - New `:font` file kind (`woff`/`woff2`/`ttf`/`otf`/`eot`), plus `m3u8` (video) and `map` (web) extensions — `Inter-Bold.woff2` now normalizes to `{file}` instead of `{font_id}`.
5
+ - **Bugfix (Rust):** out-of-range ports (`:99999`) were rejected where Ruby's deliberately lenient parser accepts them — the port field is now `Option<u64>` matching Ruby's nil-vs-integer semantics.
6
+ - **Bugfix (Ruby, SQLite backend):** rolling numeric stats (`min`/`max`/`avg` on numeric params) were never restored when loading a corpus from disk, so numeric ranges vanished from cluster output after a reopen — diverging from the Memory/JSON backends and from Rust. Now recomputed from tracked value counts on load; locked in by a new parity scenario and storage specs.
7
+ - Perf: trimmed hot-path allocations in both runtimes — Rust's classifier no longer clones the recognizer list (and locks once, not three times) per cache miss; Ruby builds each observation's Shape once instead of twice, passes the recognizer array without a splat, and drops a redundant `to_s`. No behavior change.
8
+ - **The Rust CLI's `completion` subcommand now matches Ruby exactly.** It previously carried its own divergent bash/zsh scripts plus a fish script Ruby never supported; it now embeds byte-identical copies of the shared bash/zsh scripts (parity-tested), defaults the shell from `$SHELL` like Ruby, and emits Ruby's `unknown_shell` error envelope. Fish is no longer supported. The `completion` subcommand is also listed in `--help` now.
9
+ - **Rust now supports dynamic synthesized types**, closing the last known Ruby↔Rust divergence: `--activate-above` activates a proposal under its suggested type (`activated: ghp (ghp_)`) instead of falling back to `opaque_id`, and the activated type drives `{ghp}`-style placeholders, survives corpus reopen (JSON + SQLite), and round-trips storage — parity-tested. Implementation: `SegmentType::Custom(&'static str)` backed by a leak-once interned name, keeping the enum `Copy`. Also fixes a latent Rust bug where `string`-typed param counts were dropped when reloading a JSON corpus (a hand-copied type table was missing the `string` entry).
10
+ - **The Go port is retired.** Ruby (reference) + Rust (shipped CLI via Homebrew/crates.io) continue; the `go/` module, its CI, and the Ruby↔Go / Rust↔Go parity harnesses are removed. `script/cli_parity.sh` now diffs Ruby ↔ Rust directly. Go consumers can pin `github.com/dpep/iriq/go@v0.32.1`, the last tag with Go support. `.db` corpora written by the Go binary still open cleanly (shared schema v4).
11
+
12
+ ### 0.32.1 (2026-06-26)
13
+ - Go and Rust `cluster --json` now include the per-param `values` and `value_distribution` (plus `subtype_distribution`, `kind_distribution`, and numeric `min`/`max`/`avg`) that Ruby already emitted — the cluster JSON is now identical across all three runtimes. No change to the human-readable cluster view.
14
+ - Test coverage: new `param_summary` golden fixture exercises the const → string → enum ladder + confidence across Ruby/Go/Rust (`go test` / `cargo test`), and the parity harnesses gained a key-order-agnostic JSON comparison (`jq -S` + number canonicalization) with a `cluster --json` scenario.
15
+
16
+ ### 0.32.0 (2026-06-26)
17
+ - **Query params now climb a confidence ladder: constant → string → enum.** A param with a single observed value is a constant (rendered as its value); one that varies across free-form literal values is the new `string` type (renders `{string}`); a bounded, well-supported value set is `enum` (renders `{enum}`). Previously a varying literal param just echoed whatever value you passed.
18
+ - **Enum detection is now coverage-based and straggler-robust.** An enum is promoted when its *established* values (each seen ≥ `ENUM_MIN_VALUE_COUNT`, now 3) number 2–10 and cover ≥ 90% of observations. A single brand-new value no longer knocks an established enum back down — fixing the observe-before-normalize order dependence where normalizing `?status=<new>` could flip the type. A lone repeated value is now correctly a constant, not a one-member enum.
19
+ - **New `confidence` score on every param** (`total / (total + 15)`): a 0–1 figure of how much evidence backs the classification, shown in the cluster view (`status enum conf 0.93 ...`) and the cluster JSON. The type is the guess; confidence says how sure.
20
+ - Ruby, Go, and Rust ship this together — same thresholds, same rendering, parity preserved (68/68 Ruby↔Go, 60/60 Rust↔Go).
21
+
22
+ ### 0.31.1 (2026-06-26)
23
+ - Docs: clarified the README around corpus-on-by-default — sharper IRI definition (URLs are one member of the family, alongside URNs, `mailto:`, and internationalized addresses), an accurate worked example of the corpus learning a query param is an `enum`, and a streaming example (`tail -f access.log | iriq -J`). Added the streaming example to `--help` (Ruby + Go). No behavior change.
24
+
25
+ ### 0.31.0 (2026-06-26)
26
+ - **Corpus is now on by default.** Every invocation observes into a persistent corpus, so classification gets sharper the more you use the tool — the streaming/learning behavior that was the selling point is no longer hidden behind a flag. Default location: `$XDG_DATA_HOME/iriq/default.db` on Linux, `~/Library/Application Support/iriq/default.db` on macOS, `%LOCALAPPDATA%/iriq/default.db` on Windows. First-run creation prints a one-line stderr notice.
27
+ - New flag `-C` / `--no-corpus` (and `IRIQ_NO_CORPUS=1` env) — disables the default corpus for a single invocation. Explicit `--corpus PATH` always wins, even with `--no-corpus` set in the env.
28
+ - New env var `IRIQ_CORPUS=PATH` — overrides the default location without needing `--corpus` on every call.
29
+ - New flag `--reset` — deletes the resolved corpus file (and SQLite `-wal` / `-shm` sidecars) and exits. Honors `--corpus` / `IRIQ_CORPUS` so you can reset a non-default file.
30
+ - **Go build simplification:** the slim / SQLite build split is gone. The Go binary now always links `modernc.org/sqlite` (pure Go, no cgo). `make build-sqlite`, `make release-sqlite`, and `-tags sqlite` are retired. The `dpep/tools/iriq-sqlite` Homebrew formula folds into `dpep/tools/iriq`.
31
+ - Ruby, Go, and Rust ship this together — same defaults, same flags, parity preserved.
32
+
1
33
  ### 0.30.2 (2026-06-23)
2
34
  - Piped stdin and `--file` now **stream** the per-IRI sections (`-n`/`-p`/`-c`/`-e`) line by line, flushing each IRI as it's processed — `tail -f access.log | iriq -n` is live and memory stays bounded on huge inputs. Output is byte-identical to before; the aggregate views (deduped URL list, clusters, `--stats`) still read the whole input. Ruby, Go, and Rust.
3
35
 
data/README.md CHANGED
@@ -1,5 +1,5 @@
1
- Iriq
2
- ======
1
+ # Iriq — IRI Query
2
+
3
3
  [![codecov](https://codecov.io/gh/dpep/iriq/branch/main/graph/badge.svg)](https://codecov.io/gh/dpep/iriq)
4
4
 
5
5
  **Iriq finds the *shape* of a URL** — the structural template you get when you
@@ -9,17 +9,18 @@ URLs — a log file, a column of links, free-text prose — and it collapses the
9
9
  into a small set of stable, deterministic route templates. Fifty thousand
10
10
  distinct URLs become twelve shapes.
11
11
 
12
- (An **IRI** is just a URL the internationalized superset of URI/URL that also
13
- allows non-ASCII characters. If you know URLs, you know IRIs. The name is *IRI
14
- Query*: iriq queries an IRI for its structure.)
12
+ What's an IRI? Internationalized Resource Identifiers cover everyday URLs `https://…`, plus URNs like `urn:isbn:0451450523`,
13
+ other schemes like `mailto:`, and internationalized addresses with non-ASCII
14
+ characters like `https://例え.jp/パス`. Formally it's the Unicode superset of
15
+ URI/URL. The name is *IRI Query*: iriq queries an IRI for its structure.
15
16
 
16
17
  Everything iriq does — parsing, normalizing, classifying path and query
17
18
  components, clustering, learning new patterns — exists to derive, render, or
18
19
  group by that shape.
19
20
 
20
- And it gets sharper the more you feed it. Point a *corpus* at a stream and
21
- classifications improve as data flows in high-churn slots get promoted to
22
- placeholders, and whole types emerge that you can't see in any single URL (a
21
+ And it gets sharper the more you feed it. A *corpus* on by default — records
22
+ what it sees and improves classifications as data flows in: high-churn slots get
23
+ promoted to placeholders, and whole types emerge that no single URL can reveal (a
23
24
  position that's always 100–599 is an HTTP status; one bounded to a dozen values
24
25
  is an enum).
25
26
 
@@ -70,20 +71,53 @@ $ cat access.log | iriq # ≥ 10 IRIs → cluster view
70
71
  $ cat access.log | iriq --stats # rolling aggregates
71
72
  $ iriq ./access.log -n # auto-detect file → normalize each
72
73
  $ iriq -J < access.log # newline-delimited JSON
73
- $ iriq --corpus c.db < access.log # persist into a SQLite corpus
74
+ $ iriq --corpus team.db < access.log # use a specific corpus file
75
+ ```
76
+
77
+ Per-IRI output (`-n`, `-p`, `-c`, `-J`) streams — each line is read, classified,
78
+ and flushed as it arrives, so iriq works on an unbounded live feed:
79
+
80
+ ```sh
81
+ $ tail -f access.log | iriq -n # one shape per line, as logs land
82
+ $ tail -f access.log | iriq -J # same, as newline-delimited JSON
74
83
  ```
75
84
 
76
- Once a corpus has data, `-n` becomes corpus-informed a position that only ever
77
- holds integers clusters to a single `{user_id}` shape, and new values normalize
78
- to it:
85
+ **Every invocation observes into a persistent corpus by default**, so iriq gets
86
+ smarter the more you run it. The corpus-only types (e.g. `enum` / `http_status`)
87
+ emerge from the *distribution* of values observed.
79
88
 
80
89
  ```sh
81
- $ for n in 1 2 3 4 5 6 7 8 9 10; do
82
- iriq --corpus c.db https://api.foo.com/users/$n >/dev/null
90
+ # Feed a stream where ?status only ever holds a couple of words:
91
+ $ for n in $(seq 1 20); do
92
+ iriq --corpus demo.db "https://api.foo.com/orders/$n?status=open" >/dev/null
93
+ iriq --corpus demo.db "https://api.foo.com/orders/$n?status=closed" >/dev/null
83
94
  done
84
95
 
85
- $ iriq -n --corpus c.db https://api.foo.com/users/999
86
- https://api.foo.com/users/{user_id}
96
+ # Ask what it learned. ?status is now an enum — a verdict no single URL
97
+ # could support, since one URL shows only one value:
98
+ $ iriq --corpus demo.db cluster
99
+ [40] api.foo.com /orders/{order_id}
100
+ https://api.foo.com/orders/1?status=open
101
+ https://api.foo.com/orders/1?status=closed
102
+ https://api.foo.com/orders/2?status=open
103
+ + 37 more
104
+ status enum (2 distinct, 100%)
105
+ ```
106
+
107
+ These learned types also flow into normalized output: once the corpus has pegged
108
+ `?status` as an enum, `iriq -n …?status=open` renders `?status={enum}`.
109
+
110
+ The default corpus lives at `$XDG_DATA_HOME/iriq/default.db` (Linux),
111
+ `~/Library/Application Support/iriq/default.db` (macOS), or
112
+ `%LOCALAPPDATA%/iriq/default.db` (Windows). First-run creation prints a
113
+ one-line stderr notice. Three knobs control it:
114
+
115
+ ```sh
116
+ $ iriq --no-corpus -n https://foo.com/users/123 # one-shot ephemeral; or -C
117
+ $ IRIQ_NO_CORPUS=1 iriq -n https://foo.com/users/123 # globally disable
118
+ $ IRIQ_CORPUS=/path/to/work.db iriq -n https://foo.com/users/123 # override path
119
+ $ iriq --corpus team.db https://foo.com/users/123 # explicit override (wins over env)
120
+ $ iriq --reset # delete the corpus DB and exit
87
121
  ```
88
122
 
89
123
  ### Two ways to normalize
@@ -109,39 +143,11 @@ brew install dpep/tools/iriq
109
143
 
110
144
  # Cargo, from crates.io
111
145
  cargo install iriq
112
-
113
- # Cargo, from a source checkout
114
- cargo install --path rust/iriq
115
146
  ```
116
147
 
117
148
  One crate ships both the library and the `iriq` binary. Corpora persist to
118
149
  SQLite (bundled, WAL) out of the box — nothing to flag, install, or rebuild.
119
150
 
120
- ## Use it as a Rust library
121
-
122
- ```sh
123
- cargo add iriq
124
- ```
125
-
126
- ```rust
127
- use iriq::{parse, normalize, Corpus};
128
-
129
- let iri = parse("https://foo.com/users/123")?;
130
- iri.host; // "foo.com"
131
- iri.path_segments; // ["users", "123"]
132
- iri.canonical(); // "https://foo.com/users/123"
133
-
134
- normalize("https://foo.com/users/123")?; // "https://foo.com/users/{user_id}"
135
-
136
- // Streaming clustering against a persistent corpus.
137
- let mut corpus = Corpus::open("c.db")?;
138
- corpus.observe("https://foo.com/users/1")?;
139
- corpus.save("c.db")?;
140
- ```
141
-
142
- Full API on [docs.rs/iriq](https://docs.rs/iriq); see the
143
- [crate README](rust/iriq/README.md) for the library tour.
144
-
145
151
  ## Segment classification
146
152
 
147
153
  Iriq classifies each path/query segment into one of ~25 types — the first
@@ -184,8 +190,7 @@ mechanical placeholders (`{integer}` instead of `{user_id}`).
184
190
 
185
191
  ### Types only the corpus can see
186
192
 
187
- Four types never come from a single URL they emerge from the *distribution*
188
- of values a position has held across many observations:
193
+ Four types emerge from the *distribution* of values across many observations:
189
194
 
190
195
  | Type | Emerges when a position… |
191
196
  | --- | --- |
@@ -195,16 +200,16 @@ of values a position has held across many observations:
195
200
  | `enum` | holds a small, bounded set of distinct values |
196
201
 
197
202
  Mechanically, `200` is just an integer. Across ten thousand URLs where that
198
- slot is always 100–599, it's an HTTP status. That's the corpus earning its keep.
203
+ slot is always 100–599, it's likely an HTTP status.
199
204
 
200
205
  ## Corpus (streaming + learning)
201
206
 
202
- For processing many identifiers possibly an unbounded stream — point iriq at a
203
- corpus. It maintains rolling aggregates and per-(host, prefix) frequency stats,
204
- so classification improves as more data comes in.
207
+ The corpus maintains rolling aggregates and per-(host, prefix) frequency stats,
208
+ so classification improves as more data comes in handy for an unbounded stream
209
+ of identifiers. The default corpus already persists; `--corpus PATH` points iriq
210
+ at a specific file instead, to keep separate corpora or share one across runs.
205
211
 
206
- `--corpus PATH` makes the corpus survive across invocations. A `.db` /
207
- `.sqlite` / `.sqlite3` path is stored in SQLite (WAL journaling, incremental
212
+ A `.db` / `.sqlite` / `.sqlite3` path is stored in SQLite (WAL journaling, incremental
208
213
  UPSERTs — multiple `iriq --corpus` processes can write concurrently); a
209
214
  `.json` path writes a plain JSON file instead.
210
215
 
@@ -225,9 +230,7 @@ $ iriq --corpus c.db --reinfer
225
230
  Iriq doesn't just classify against a fixed list — it watches the stream and
226
231
  *proposes new recognizers* for patterns it keeps seeing. Notice `ghp_…` or
227
232
  `cus_…` recurring at a slug position and iriq will suggest a recognizer for it,
228
- with evidence: coverage, host count, confidence. Proposals are never
229
- auto-applied — you activate the ones you trust, and they persist with the
230
- corpus. Human-in-the-loop by design.
233
+ with evidence: coverage, host count, confidence.
231
234
 
232
235
  ```sh
233
236
  # Print proposals (human-readable, or --json)
@@ -291,11 +294,14 @@ an ephemeral corpus.
291
294
  | `-p, --parse` | Show parsed fields |
292
295
  | `-n, --normalize` | Show the shape-normalized form |
293
296
  | `-c, --canonical` | Show the canonical form (no shape normalization) |
297
+ | `-e, --explain` | Annotated trace — per-segment notes about why each placeholder / canonical value was chosen |
294
298
  | `-j, --json` | Emit JSON |
295
299
  | `-J, --ndjson` | Newline-delimited JSON (one object per line); implies `--json` |
296
300
  | `-N, --no-hints` | Use `{integer}` etc. instead of `{user_id}` |
297
301
  | `--no-scheme-less` | Skip `foo.com/path`-style extraction (explicit-scheme only) |
298
- | `--corpus PATH` | Load/create a corpus at PATH (`.json` or `.db`/`.sqlite`/`.sqlite3`) |
302
+ | `--corpus PATH` | Use a specific corpus file (`.json` or `.db`/`.sqlite`/`.sqlite3`). Overrides the default |
303
+ | `-C, --no-corpus` | Disable corpus persistence for this invocation (same as `IRIQ_NO_CORPUS=1`) |
304
+ | `--reset` | Delete the corpus database and exit |
299
305
  | `--host MODE` | Host-keying for clustering: `full` (default), `reg` strips subdomains, `none` ignores host |
300
306
  | `--stats` | Print rolling aggregates |
301
307
  | `--reinfer` | Drop the materialized views and replay the source-IRI log through the current classifier + reducers |
@@ -308,13 +314,44 @@ an ephemeral corpus.
308
314
  | `completion bash\|zsh` | Print shell completion script (Homebrew installs this automatically) |
309
315
  | `-V, --version` | Print version |
310
316
 
317
+ Environment variables:
318
+
319
+ | Variable | Effect |
320
+ | -------------------- | ------------------------------------------------------- |
321
+ | `IRIQ_CORPUS=PATH` | Set the corpus path (overrides the default) |
322
+ | `IRIQ_NO_CORPUS=1` | Disable the default corpus (equivalent to `-C`) |
323
+
311
324
  A positional argument that doesn't parse as an IRI but IS an existing file is
312
325
  read and extracted from automatically — `iriq ./access.log` and
313
- `iriq /var/log/foo.log` Just Work. (Bare filenames like `README.md` may still
314
- parse as a URL; pipe with `cat` to disambiguate.)
326
+ `iriq /var/log/foo.log` Just Work. (pipe with `cat` to disambiguate)
315
327
 
316
328
  Exit codes: `0` success, `1` usage error, `2` parse error.
317
329
 
330
+ ## Rust library
331
+
332
+ ```sh
333
+ cargo add iriq
334
+ ```
335
+
336
+ ```rust
337
+ use iriq::{parse, normalize, Corpus};
338
+
339
+ let iri = parse("https://foo.com/users/123")?;
340
+ iri.host; // "foo.com"
341
+ iri.path_segments; // ["users", "123"]
342
+ iri.canonical(); // "https://foo.com/users/123"
343
+
344
+ normalize("https://foo.com/users/123")?; // "https://foo.com/users/{user_id}"
345
+
346
+ // Streaming clustering against a persistent corpus.
347
+ let mut corpus = Corpus::open("c.db")?;
348
+ corpus.observe("https://foo.com/users/1")?;
349
+ corpus.save("c.db")?;
350
+ ```
351
+
352
+ Full API on [docs.rs/iriq](https://docs.rs/iriq); see the
353
+ [crate README](rust/iriq/README.md) for the library tour.
354
+
318
355
  ## Limitations (intentional)
319
356
 
320
357
  Iriq does **not**:
@@ -330,15 +367,3 @@ Iriq does **not**:
330
367
 
331
368
  Iriq's focus is the analysis side: classification, normalization, and clustering
332
369
  — not a complete URL implementation.
333
-
334
- ----
335
- ## Contributing
336
-
337
- Yes please :)
338
-
339
- 1. Fork it
340
- 1. Create your feature branch (`git checkout -b my-feature`)
341
- 1. Ensure the tests pass (`cd rust && cargo test`)
342
- 1. Commit your changes (`git commit -am 'awesome new feature'`)
343
- 1. Push your branch (`git push origin my-feature`)
344
- 1. Create a Pull Request
data/completions/_iriq CHANGED
@@ -27,6 +27,8 @@ _iriq() {
27
27
  '--no-scheme-less[skip schemeless URL extraction]' \
28
28
  '--scheme-less[enable schemeless URL extraction]' \
29
29
  '--corpus[load/create a JSON or SQLite corpus]:corpus path:_files -g "*.(json|db|sqlite|sqlite3)"' \
30
+ '(-C --no-corpus)'{-C,--no-corpus}'[disable corpus persistence for this invocation]' \
31
+ '--reset[delete the corpus database and exit]' \
30
32
  '--host[host-keying strategy for clustering]:strategy:(full registrable reg none)' \
31
33
  '--stats[print rolling aggregates]' \
32
34
  '--reinfer[replay the source-IRI log]' \
@@ -44,7 +44,7 @@ _iriq() {
44
44
  if [[ "$cur" == -* ]]; then
45
45
  local flags="-h --help -V --version -p --parse -n --normalize -c --canonical -e --explain
46
46
  -j --json -J --ndjson -N --no-hints --hints --no-scheme-less
47
- --scheme-less --corpus --host --stats --reinfer
47
+ --scheme-less --corpus -C --no-corpus --reset --host --stats --reinfer
48
48
  --propose-recognizers --activate-above --cross-host-shapes
49
49
  --min-observations --min-coverage --min-hosts"
50
50
  COMPREPLY=( $(compgen -W "$flags" -- "$cur") )
data/iriq.gemspec CHANGED
@@ -5,7 +5,7 @@ Gem::Specification.new do |s|
5
5
  s.version = Iriq::VERSION
6
6
  s.authors = ["Daniel Pepper"]
7
7
  s.description = "IRI extraction, normalization, and clustering."
8
- s.files = `git ls-files * ':!:spec' ':!:script' ':!:bin' ':!:rust' ':!:go'`.split("\n")
8
+ s.files = `git ls-files lib exe completions README.md LICENSE.txt CHANGELOG.md iriq.gemspec`.split("\n")
9
9
  s.bindir = "exe"
10
10
  s.executables = ["iriq"]
11
11
  s.homepage = "https://github.com/dpep/iriq"
data/lib/iriq/cli.rb CHANGED
@@ -1,3 +1,4 @@
1
+ require "fileutils"
1
2
  require "json"
2
3
  require "optparse"
3
4
  require "stringio"
@@ -35,30 +36,37 @@ module Iriq
35
36
  each placeholder / canonical value was chosen
36
37
 
37
38
  Corpus + stats:
38
- --corpus PATH Load/create a JSON corpus; observe and save atomically.
39
- -n becomes corpus-informed once it has data.
39
+ --corpus PATH Use a specific corpus file (overrides the default).
40
+ Extension picks the backend: .db/.sqlite/.sqlite3
41
+ are SQLite; anything else is JSON.
42
+ -C, --no-corpus Disable corpus persistence for this invocation.
43
+ Same as IRIQ_NO_CORPUS=1 in the environment.
44
+ --reset Delete the corpus database (default path or the
45
+ one resolved via --corpus / IRIQ_CORPUS) and exit.
40
46
  --host MODE Host-keying strategy for clustering:
41
47
  full (default), registrable (or reg) strips
42
48
  subdomains, none ignores host entirely.
43
49
  --stats Print rolling aggregates
44
50
  --reinfer Replay the source-IRI log through the current
45
51
  classifier + reducers; rebuilds materialized
46
- views from scratch. Requires --corpus.
52
+ views from scratch.
47
53
  --propose-recognizers
48
54
  Scan observed values for shape patterns that
49
55
  recur enough to suggest a new Recognizer.
50
56
  Combine with --json for structured output.
51
- Requires --corpus.
52
57
  --cross-host-shapes
53
58
  List route shapes that recur across
54
59
  multiple hosts. Combine with --min-hosts.
55
- Requires --corpus.
56
60
  --activate-above F With --propose-recognizers, promote every
57
61
  proposal at or above CONFIDENCE F into a
58
62
  live Recognizer on the corpus, then
59
63
  reinfer. Confidence integrates coverage
60
64
  and cross-host corroboration.
61
65
 
66
+ Environment:
67
+ IRIQ_CORPUS=PATH Set the corpus path (overrides the default).
68
+ IRIQ_NO_CORPUS=1 Disable the default corpus (equivalent to -C).
69
+
62
70
  Thresholds (apply to --propose-recognizers / --cross-host-shapes):
63
71
  --min-observations N proposal noise floor (default 20)
64
72
  --min-coverage F proposal coverage floor (default 0.7)
@@ -76,12 +84,14 @@ module Iriq
76
84
 
77
85
  Subcommands:
78
86
  cluster [file] Force cluster view (default for ≥10 IRIs anyway)
87
+ completion <shell> Print shell completion script (bash | zsh)
79
88
 
80
89
  Examples:
81
90
  iriq foo.com/users/456
82
91
  iriq -n https://foo.com/users/123
83
92
  iriq ./access.log # auto-detect file → extract URLs
84
93
  cat README.md | iriq -n # one normalized URL per line
94
+ tail -f access.log | iriq -J # live stream → NDJSON per IRI
85
95
  cat README.md | iriq --corpus c.json
86
96
  TXT
87
97
 
@@ -123,9 +133,17 @@ module Iriq
123
133
  batch_mode = explicit_cluster || positional_is_file ||
124
134
  (args.empty? && piped_stdin?)
125
135
 
136
+ # --reset short-circuits: delete the resolved corpus file (+ sidecars)
137
+ # and exit. Resolves through the same precedence chain as the normal
138
+ # path so `--reset --corpus other.db` and `IRIQ_CORPUS=… --reset` Just Work.
139
+ if opts[:reset]
140
+ return cmd_reset(opts)
141
+ end
142
+
126
143
  return print_usage(stdout, 0) if args.empty? && !batch_mode && !opts[:reinfer] && !opts[:propose] && !opts[:cross_host_shapes]
127
144
 
128
- corpus = opts[:corpus] ? load_corpus(opts[:corpus], host_strategy: opts[:host_strategy]) : nil
145
+ corpus_path = resolve_corpus_path(opts)
146
+ corpus = corpus_path ? load_corpus(corpus_path, host_strategy: opts[:host_strategy], announce_create: true) : nil
129
147
 
130
148
  code = if opts[:reinfer]
131
149
  cmd_reinfer(corpus, opts)
@@ -141,7 +159,7 @@ module Iriq
141
159
  cmd_summary(args, opts, corpus)
142
160
  end
143
161
 
144
- corpus.save(opts[:corpus]) if corpus && opts[:corpus]
162
+ corpus.save(corpus_path) if corpus && corpus_path
145
163
  code
146
164
  rescue Iriq::ParseError => e
147
165
  emit_error("parse_error", e.message, 2, human: "iriq: parse error: #{e.message}")
@@ -167,6 +185,8 @@ module Iriq
167
185
  hints: true,
168
186
  sections: [],
169
187
  corpus: nil,
188
+ no_corpus: false,
189
+ reset: false,
170
190
  stats: false,
171
191
  reinfer: false,
172
192
  propose: false,
@@ -191,6 +211,8 @@ module Iriq
191
211
  o.on("--[no-]hints") { |v| opts[:hints] = v }
192
212
  o.on("-N") { opts[:hints] = false }
193
213
  o.on("--corpus PATH") { |v| opts[:corpus] = v }
214
+ o.on("-C", "--no-corpus") { opts[:no_corpus] = true }
215
+ o.on("--reset") { opts[:reset] = true }
194
216
  o.on("--host MODE") { |v| opts[:host_strategy] = host_strategy_arg(v) }
195
217
  o.on("--stats") { opts[:stats] = true }
196
218
  o.on("--reinfer") { opts[:reinfer] = true }
@@ -220,10 +242,79 @@ module Iriq
220
242
  end
221
243
  end
222
244
 
223
- def load_corpus(path, host_strategy: :full)
245
+ def load_corpus(path, host_strategy: :full, announce_create: false)
246
+ if announce_create && !File.exist?(path)
247
+ FileUtils.mkdir_p(File.dirname(path))
248
+ stderr.puts "iriq: created corpus at #{path} (disable with --no-corpus or IRIQ_NO_CORPUS=1)"
249
+ end
224
250
  Corpus.open(path, host_strategy: host_strategy)
225
251
  end
226
252
 
253
+ # Resolve the corpus file the CLI should use. Precedence:
254
+ # 1. --corpus PATH — explicit always wins (you asked for it)
255
+ # 2. --no-corpus / IRIQ_NO_CORPUS=1 — opt out of the default
256
+ # 3. IRIQ_CORPUS=PATH — env override of the default location
257
+ # 4. default_corpus_path — platform-aware location
258
+ def resolve_corpus_path(opts)
259
+ return opts[:corpus] if opts[:corpus]
260
+ return nil if opts[:no_corpus] || env_corpus_disabled?
261
+
262
+ env_path = ENV["IRIQ_CORPUS"].to_s
263
+ return env_path unless env_path.empty?
264
+
265
+ default_corpus_path
266
+ end
267
+
268
+ # Platform-aware default. XDG-honoring on Linux + BSD, Apple-style on
269
+ # macOS, %LOCALAPPDATA% on Windows. Same logic in Rust so both
270
+ # runtimes share the same default.db.
271
+ def default_corpus_path
272
+ base = if (xdg = ENV["XDG_DATA_HOME"].to_s) && !xdg.empty?
273
+ File.join(xdg, "iriq")
274
+ elsif RUBY_PLATFORM =~ /darwin/
275
+ File.expand_path("~/Library/Application Support/iriq")
276
+ elsif RUBY_PLATFORM =~ /mingw|mswin|cygwin/
277
+ File.join(ENV["LOCALAPPDATA"].to_s.empty? ? File.expand_path("~/AppData/Local") : ENV["LOCALAPPDATA"], "iriq")
278
+ else
279
+ File.expand_path("~/.local/share/iriq")
280
+ end
281
+ File.join(base, "default.db")
282
+ end
283
+
284
+ def env_corpus_disabled?
285
+ v = ENV["IRIQ_NO_CORPUS"].to_s.downcase
286
+ !v.empty? && v != "0" && v != "false" && v != "no"
287
+ end
288
+
289
+ def cmd_reset(opts)
290
+ path = resolve_reset_path(opts)
291
+ unless path
292
+ return emit_error("missing_argument", "no corpus path to reset (use --corpus PATH or unset --no-corpus)", 1)
293
+ end
294
+ removed = []
295
+ [path, "#{path}-wal", "#{path}-shm", "#{path}.tmp"].each do |p|
296
+ if File.exist?(p)
297
+ File.delete(p)
298
+ removed << p
299
+ end
300
+ end
301
+ if removed.empty?
302
+ stderr.puts "iriq: no corpus to reset at #{path}"
303
+ else
304
+ stderr.puts "iriq: reset corpus at #{path}"
305
+ end
306
+ 0
307
+ end
308
+
309
+ # --reset honors --corpus / IRIQ_CORPUS even when --no-corpus is set —
310
+ # the user is explicitly addressing a stored file, not the runtime state.
311
+ def resolve_reset_path(opts)
312
+ return opts[:corpus] if opts[:corpus]
313
+ env_path = ENV["IRIQ_CORPUS"].to_s
314
+ return env_path unless env_path.empty?
315
+ default_corpus_path
316
+ end
317
+
227
318
  # Accept `--host=reg` as a short alias for the `registrable` mode.
228
319
  HOST_STRATEGY_ALIASES = {
229
320
  "full" => :full, "registrable" => :registrable, "reg" => :registrable, "none" => :none,
@@ -473,9 +564,10 @@ module Iriq
473
564
  end
474
565
 
475
566
  # `completion <shell>` — emit the bundled shell-completion script.
476
- # Scripts live in completions/{iriq.bash,_iriq} alongside the gem;
477
- # Homebrew installs them automatically, but the user can also do
478
- # `source <(iriq completion bash)` in their shell rc.
567
+ # Scripts live in completions/{iriq.bash,_iriq} alongside the
568
+ # gem; Homebrew installs them automatically, but the user can also do
569
+ # `source <(iriq completion bash)` in their shell rc. The Rust CLI
570
+ # inlines the same scripts — keep them byte-identical (parity-tested).
479
571
  COMPLETIONS_DIR = File.expand_path("../../completions", __dir__).freeze
480
572
  COMPLETION_FILES = {
481
573
  "bash" => File.join(COMPLETIONS_DIR, "iriq.bash"),
@@ -653,7 +745,10 @@ module Iriq
653
745
  end
654
746
 
655
747
  def emit_clusters(clusters, opts)
656
- sorted = clusters.sort_by { |c| -c.count }
748
+ # Stable sort: equal-count clusters keep first-seen order. Ruby's
749
+ # sort_by is unstable, so ties need the explicit index tie-break to
750
+ # match the Rust CLI's (stable) sort.
751
+ sorted = clusters.sort_by.with_index { |c, i| [-c.count, i] }
657
752
 
658
753
  if opts[:json]
659
754
  emit_json(sorted.map(&:to_h), opts)
@@ -672,8 +767,8 @@ module Iriq
672
767
  end
673
768
  end
674
769
 
675
- # One line per param: type, range (numeric), cardinality, presence.
676
- # `page integer 1..100 avg 50.5 (10 distinct, 100%)`
770
+ # One line per param: type, confidence, range (numeric), cardinality,
771
+ # presence. `page integer conf 0.87 1..100 avg 50.5 (10 distinct, 100%)`
677
772
  def emit_param_summary(cluster)
678
773
  rows = cluster.param_summary
679
774
  return if rows.empty?
@@ -681,6 +776,7 @@ module Iriq
681
776
  width = rows.map { |r| r[:name].length }.max
682
777
  rows.each do |r|
683
778
  bits = ["#{r[:type]}"]
779
+ bits << "conf #{format_conf(r[:confidence])}" if r[:confidence]
684
780
  if r[:min] && r[:max]
685
781
  bits << format_range(r[:min], r[:max])
686
782
  bits << "avg #{format_num(r[:avg])}" if r[:avg]
@@ -690,6 +786,11 @@ module Iriq
690
786
  end
691
787
  end
692
788
 
789
+ # Two-decimal confidence, e.g. 0.87. Matches the Rust CLI formatting.
790
+ def format_conf(conf)
791
+ format("%.2f", conf)
792
+ end
793
+
693
794
  def format_range(lo, hi)
694
795
  "#{format_num(lo)}..#{format_num(hi)}"
695
796
  end
@@ -737,9 +838,8 @@ module Iriq
737
838
  end
738
839
 
739
840
  def top(hash)
740
- # Lex tie-break on equal counts — Ruby Hash insertion order would
741
- # otherwise diverge from Go's map iteration (which has no insertion
742
- # order). Keeps Ruby ↔ Go --stats parity stable.
841
+ # Lex tie-break on equal counts — keeps --stats output deterministic
842
+ # and Ruby Rust parity stable regardless of insertion order.
743
843
  hash.sort_by { |k, n| [-n, k] }.first(TOP_N_STATS).to_h
744
844
  end
745
845
  end