iriq 0.2.0 → 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.
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"
@@ -18,6 +19,8 @@ module Iriq
18
19
  LARGE_BATCH_THRESHOLD = 10
19
20
 
20
21
  USAGE = <<~TXT
22
+ iriq — find a URL's shape: the route template behind it (e.g. /users/{id}).
23
+
21
24
  Usage: iriq [options] <input>
22
25
  iriq [options] < text
23
26
  iriq cluster [options] [file]
@@ -26,29 +29,69 @@ module Iriq
26
29
  text via stdin.
27
30
 
28
31
  Sections (combine freely):
29
- -n, --normalize Shape-normalized form
32
+ -n, --normalize Shape — variable parts become placeholders
33
+ -c, --canonical Clean form — tidy scheme/host, keep the values
30
34
  -p, --parse Parsed fields
35
+ -e, --explain Annotated trace — per-segment notes about why
36
+ each placeholder / canonical value was chosen
31
37
 
32
38
  Corpus + stats:
33
- --corpus PATH Load/create a JSON corpus; observe and save atomically.
34
- -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.
46
+ --host MODE Host-keying strategy for clustering:
47
+ full (default), registrable (or reg) strips
48
+ subdomains, none ignores host entirely.
35
49
  --stats Print rolling aggregates
50
+ --reinfer Replay the source-IRI log through the current
51
+ classifier + reducers; rebuilds materialized
52
+ views from scratch.
53
+ --propose-recognizers
54
+ Scan observed values for shape patterns that
55
+ recur enough to suggest a new Recognizer.
56
+ Combine with --json for structured output.
57
+ --cross-host-shapes
58
+ List route shapes that recur across
59
+ multiple hosts. Combine with --min-hosts.
60
+ --activate-above F With --propose-recognizers, promote every
61
+ proposal at or above CONFIDENCE F into a
62
+ live Recognizer on the corpus, then
63
+ reinfer. Confidence integrates coverage
64
+ and cross-host corroboration.
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
+
70
+ Thresholds (apply to --propose-recognizers / --cross-host-shapes):
71
+ --min-observations N proposal noise floor (default 20)
72
+ --min-coverage F proposal coverage floor (default 0.7)
73
+ --min-hosts N proposal: minimum hosts (default 1);
74
+ cross-host-shapes: minimum hosts to
75
+ list (default 2)
36
76
 
37
77
  Other:
38
78
  -h, --help Show this message
39
79
  -j, --json Emit JSON instead of human-readable output
40
- -N, --no-hints Use {integer_id} placeholders instead of {user_id}
80
+ -J, --ndjson Newline-delimited JSON (one object per line). Implies --json.
81
+ -N, --no-hints Use {integer} placeholders instead of {user_id}
41
82
  --no-scheme-less Skip foo.com/path extraction (explicit-scheme only)
42
83
  -V, --version Print version
43
84
 
44
85
  Subcommands:
45
86
  cluster [file] Force cluster view (default for ≥10 IRIs anyway)
87
+ completion <shell> Print shell completion script (bash | zsh)
46
88
 
47
89
  Examples:
48
90
  iriq foo.com/users/456
49
91
  iriq -n https://foo.com/users/123
50
92
  iriq ./access.log # auto-detect file → extract URLs
51
93
  cat README.md | iriq -n # one normalized URL per line
94
+ tail -f access.log | iriq -J # live stream → NDJSON per IRI
52
95
  cat README.md | iriq --corpus c.json
53
96
  TXT
54
97
 
@@ -62,11 +105,22 @@ module Iriq
62
105
 
63
106
  # Returns an integer exit code.
64
107
  def run(argv)
108
+ # Pre-scan so an error during option parsing can still honor --json.
109
+ # Re-set authoritatively from opts once parsing succeeds.
110
+ @json = json_requested?(argv)
65
111
  args, opts = parse_options(argv)
112
+ @json = opts[:json]
66
113
 
67
114
  return print_usage(stdout, 0) if opts[:help]
68
115
  return print_version if opts[:version]
69
116
 
117
+ # `iriq completion <shell>` short-circuits — no corpus, no IRI input,
118
+ # just emit the script bundled with the gem.
119
+ if args.first == "completion"
120
+ args.shift
121
+ return cmd_completion(args)
122
+ end
123
+
70
124
  explicit_cluster = (args.first == "cluster")
71
125
  args.shift if explicit_cluster
72
126
 
@@ -79,11 +133,25 @@ module Iriq
79
133
  batch_mode = explicit_cluster || positional_is_file ||
80
134
  (args.empty? && piped_stdin?)
81
135
 
82
- return print_usage(stdout, 0) if args.empty? && !batch_mode
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
+
143
+ return print_usage(stdout, 0) if args.empty? && !batch_mode && !opts[:reinfer] && !opts[:propose] && !opts[:cross_host_shapes]
83
144
 
84
- corpus = opts[:corpus] ? load_corpus(opts[:corpus]) : 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
85
147
 
86
- code = if batch_mode
148
+ code = if opts[:reinfer]
149
+ cmd_reinfer(corpus, opts)
150
+ elsif opts[:propose]
151
+ cmd_propose(corpus, opts)
152
+ elsif opts[:cross_host_shapes]
153
+ cmd_cross_host_shapes(corpus, opts)
154
+ elsif batch_mode
87
155
  cmd_batch(args, opts, corpus, explicit_cluster: explicit_cluster)
88
156
  elsif opts[:stats]
89
157
  cmd_stats(corpus, opts)
@@ -91,14 +159,12 @@ module Iriq
91
159
  cmd_summary(args, opts, corpus)
92
160
  end
93
161
 
94
- corpus.save(opts[:corpus]) if corpus && opts[:corpus]
162
+ corpus.save(corpus_path) if corpus && corpus_path
95
163
  code
96
164
  rescue Iriq::ParseError => e
97
- stderr.puts "iriq: parse error: #{e.message}"
98
- 2
165
+ emit_error("parse_error", e.message, 2, human: "iriq: parse error: #{e.message}")
99
166
  rescue OptionParser::ParseError => e
100
- stderr.puts "iriq: #{e.message}"
101
- 1
167
+ emit_error("option_error", e.message, 1)
102
168
  end
103
169
 
104
170
  def parseable_iri?(input)
@@ -113,22 +179,49 @@ module Iriq
113
179
  def parse_options(argv)
114
180
  opts = {
115
181
  json: false,
182
+ ndjson: false,
116
183
  help: false,
117
184
  version: false,
118
185
  hints: true,
119
186
  sections: [],
120
- corpus: nil,
121
- stats: false,
122
- scheme_less: true,
187
+ corpus: nil,
188
+ no_corpus: false,
189
+ reset: false,
190
+ stats: false,
191
+ reinfer: false,
192
+ propose: false,
193
+ propose_min_obs: nil,
194
+ propose_min_coverage: nil,
195
+ # --min-hosts is generic: it applies to both --propose-recognizers
196
+ # (proposal threshold) and --cross-host-shapes (cross-host
197
+ # recurrence threshold).
198
+ min_hosts: nil,
199
+ activate_above: nil,
200
+ cross_host_shapes: false,
201
+ scheme_less: true,
202
+ host_strategy: :full,
123
203
  }
124
204
  parser = OptionParser.new do |o|
125
205
  o.on("-p", "--parse") { opts[:sections] << :parse }
126
206
  o.on("-n", "--normalize") { opts[:sections] << :normalize }
207
+ o.on("-c", "--canonical") { opts[:sections] << :canonical }
208
+ o.on("-e", "--explain") { opts[:sections] << :explain }
127
209
  o.on("-j", "--json") { opts[:json] = true }
210
+ o.on("-J", "--ndjson") { opts[:json] = true; opts[:ndjson] = true }
128
211
  o.on("--[no-]hints") { |v| opts[:hints] = v }
129
212
  o.on("-N") { opts[:hints] = false }
130
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 }
216
+ o.on("--host MODE") { |v| opts[:host_strategy] = host_strategy_arg(v) }
131
217
  o.on("--stats") { opts[:stats] = true }
218
+ o.on("--reinfer") { opts[:reinfer] = true }
219
+ o.on("--propose-recognizers") { opts[:propose] = true }
220
+ o.on("--min-observations N", Integer) { |v| opts[:propose_min_obs] = v }
221
+ o.on("--min-coverage F", Float) { |v| opts[:propose_min_coverage] = v }
222
+ o.on("--min-hosts N", Integer) { |v| opts[:min_hosts] = v }
223
+ o.on("--activate-above F", Float) { |v| opts[:activate_above] = v }
224
+ o.on("--cross-host-shapes") { opts[:cross_host_shapes] = true }
132
225
  o.on("--[no-]scheme-less") { |v| opts[:scheme_less] = v }
133
226
  o.on("-h", "--help") { opts[:help] = true }
134
227
  o.on("-V", "--version") { opts[:version] = true }
@@ -149,8 +242,89 @@ module Iriq
149
242
  end
150
243
  end
151
244
 
152
- def load_corpus(path)
153
- Corpus.open(path)
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
250
+ Corpus.open(path, host_strategy: host_strategy)
251
+ end
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
+
318
+ # Accept `--host=reg` as a short alias for the `registrable` mode.
319
+ HOST_STRATEGY_ALIASES = {
320
+ "full" => :full, "registrable" => :registrable, "reg" => :registrable, "none" => :none,
321
+ }.freeze
322
+
323
+ def host_strategy_arg(value)
324
+ mode = HOST_STRATEGY_ALIASES[value.to_s.downcase]
325
+ raise OptionParser::InvalidArgument, "--host: expected full|registrable|reg|none, got #{value.inspect}" unless mode
326
+
327
+ mode
154
328
  end
155
329
 
156
330
  def print_usage(io, code)
@@ -171,9 +345,13 @@ module Iriq
171
345
 
172
346
  data = {}
173
347
  data[:parse] = identifier_hash(iri) if sections.include?(:parse)
348
+ data[:canonical] = iri.canonical if sections.include?(:canonical)
174
349
  if sections.include?(:normalize)
175
350
  data[:normalize] = corpus ? corpus.normalize(iri) : Normalizer.normalize_identifier(iri, hints: opts[:hints])
176
351
  end
352
+ if sections.include?(:explain)
353
+ data[:explain] = Trace.for(iri, hints: opts[:hints])
354
+ end
177
355
 
178
356
  if opts[:json]
179
357
  payload = sections.size == 1 ? data.values.first : data
@@ -190,12 +368,21 @@ module Iriq
190
368
  # corpus is ephemeral unless --corpus was given.
191
369
  def cmd_batch(args, opts, corpus, explicit_cluster: false)
192
370
  corpus ||= Corpus.new
371
+
372
+ # Per-IRI sections (-n/-p/-c/-e) are independent line to line, so we
373
+ # stream: read input lazily, extract per line, and emit each IRI as it
374
+ # arrives (flushed for live `tail -f | iriq -n` pipelines). The aggregate
375
+ # views below — stats, clusters, the deduped URL list — need the whole
376
+ # input, so they slurp.
377
+ if opts[:sections].any?
378
+ emit_per_iri_sections(lazy_iris(args.first, opts), opts, corpus)
379
+ return 0
380
+ end
381
+
193
382
  iris = extract_text(read_text(args.first), opts)
194
383
  corpus.batch { iris.each { |iri| corpus.observe(iri) } }
195
384
 
196
- if opts[:sections].any?
197
- emit_per_iri_sections(iris, opts)
198
- elsif opts[:stats]
385
+ if opts[:stats]
199
386
  emit_stats(corpus, opts)
200
387
  elsif explicit_cluster || iris.size >= LARGE_BATCH_THRESHOLD
201
388
  # Either the user asked for clusters explicitly, or the input is
@@ -207,36 +394,68 @@ module Iriq
207
394
  0
208
395
  end
209
396
 
210
- # Emit the requested sections (parse/normalize/explain) for each
211
- # extracted IRI. -n alone is the cleanest case: one line per URL.
212
- def emit_per_iri_sections(iris, opts)
397
+ # Lazily yield IRIs from the input, one input line at a time, so an
398
+ # unbounded stream flows through without being buffered in full. Matches
399
+ # whole-text extraction exactly: a candidate never spans a newline
400
+ # (URL_CHAR_CLASS excludes whitespace) and `extract` does not dedup.
401
+ def lazy_iris(path, opts)
402
+ extractor = Extractor.new(scheme_less: opts[:scheme_less])
403
+ input_lines(path).lazy.flat_map { |line| extractor.extract(line) }
404
+ end
405
+
406
+ def input_lines(path)
407
+ if path.nil? || path == "-"
408
+ stdin.each_line
409
+ else
410
+ File.foreach(path)
411
+ end
412
+ end
413
+
414
+ # Emit the requested sections (parse/normalize/explain) for each extracted
415
+ # IRI, observing each into `corpus` as it passes. `iris` may be a lazy
416
+ # enumerator; human and NDJSON output stream (flushed per IRI) while a single
417
+ # JSON array must be materialized. -n alone is the cleanest case: one line
418
+ # per URL.
419
+ def emit_per_iri_sections(iris, opts, corpus)
213
420
  sections = opts[:sections]
214
- payloads = iris.map { |iri| section_payload(iri, sections, opts) }
215
421
 
216
- if opts[:json]
422
+ # A wrapping JSON array can't be emitted incrementally — collect it
423
+ # (force the lazy enumerator to a real Array so emit_json sees an array).
424
+ if opts[:json] && !opts[:ndjson]
425
+ payloads = iris.map { |iri| corpus.observe(iri); section_payload(iri, sections, opts) }.to_a
217
426
  out = sections.size == 1 ? payloads.map(&:values).flatten(1) : payloads
218
- stdout.puts JSON.generate(out)
219
- elsif sections == [:normalize]
220
- # Most common case — keep it tight: one URL per line, no headers.
221
- payloads.each { |p| stdout.puts p[:normalize] }
222
- else
223
- payloads.each_with_index do |p, i|
427
+ return emit_json(out, opts)
428
+ end
429
+
430
+ iris.each_with_index do |iri, i|
431
+ corpus.observe(iri)
432
+ p = section_payload(iri, sections, opts)
433
+ if opts[:ndjson]
434
+ items = sections.size == 1 ? p.values : [p]
435
+ items.each { |item| stdout.puts JSON.generate(item) }
436
+ elsif sections == [:normalize] || sections == [:canonical]
437
+ # Most common case — keep it tight: one URL per line, no headers.
438
+ stdout.puts p[sections.first]
439
+ else
224
440
  stdout.puts if i > 0
225
- stdout.puts "# #{iris[i].canonical}"
441
+ stdout.puts "# #{iri.canonical}"
226
442
  sections.each_with_index do |sec, j|
227
443
  stdout.puts if j > 0 # blank line between sections within one IRI
228
444
  case sec
229
445
  when :parse then emit_parse_human(p[:parse])
446
+ when :canonical then stdout.puts p[:canonical]
230
447
  when :normalize then stdout.puts p[:normalize]
231
448
  end
232
449
  end
233
450
  end
451
+ stdout.flush
234
452
  end
235
453
  end
236
454
 
237
455
  def section_payload(iri, sections, opts)
238
456
  data = {}
239
457
  data[:parse] = identifier_hash(iri) if sections.include?(:parse)
458
+ data[:canonical] = iri.canonical if sections.include?(:canonical)
240
459
  data[:normalize] = Normalizer.normalize_identifier(iri, hints: opts[:hints]) if sections.include?(:normalize)
241
460
  data
242
461
  end
@@ -260,7 +479,7 @@ module Iriq
260
479
  sorted = counts.sort_by { |k, c| [-c, first[k]] }
261
480
 
262
481
  if opts[:json]
263
- stdout.puts JSON.generate(sorted.map { |k, c| { iri: k, count: c } })
482
+ emit_json(sorted.map { |k, c| { iri: k, count: c } }, opts)
264
483
  elsif sorted.all? { |_, c| c == 1 }
265
484
  sorted.each { |k, _| stdout.puts k }
266
485
  else
@@ -275,9 +494,153 @@ module Iriq
275
494
  0
276
495
  end
277
496
 
497
+ # --propose-recognizers: scan observed values for prefix patterns
498
+ # that recur enough to suggest a new Recognizer. Prints one block
499
+ # per proposal in human mode, or a JSON array under --json. With
500
+ # --activate-above F, every proposal at or above coverage F is
501
+ # promoted to a live Recognizer on the corpus's classifier and the
502
+ # corpus reinfers to apply the new classifier to existing
503
+ # observations.
504
+ def cmd_propose(corpus, opts)
505
+ return missing("--corpus") unless corpus
506
+
507
+ kwargs = {}
508
+ kwargs[:min_observations] = opts[:propose_min_obs] if opts[:propose_min_obs]
509
+ kwargs[:min_coverage] = opts[:propose_min_coverage] if opts[:propose_min_coverage]
510
+ kwargs[:min_hosts] = opts[:min_hosts] if opts[:min_hosts]
511
+
512
+ if opts[:activate_above]
513
+ activated = corpus.activate_proposals_above(opts[:activate_above], **kwargs)
514
+ if activated.empty?
515
+ stdout.puts "no proposals at or above coverage #{opts[:activate_above]}"
516
+ else
517
+ activated.each do |r|
518
+ stdout.puts "activated: #{r.type} (#{r.prefix})"
519
+ end
520
+ end
521
+ return 0
522
+ end
523
+
524
+ proposals = corpus.propose_recognizers(**kwargs)
525
+
526
+ if opts[:json]
527
+ stdout.puts JSON.generate(proposals.map(&:to_h))
528
+ return 0
529
+ end
530
+
531
+ if proposals.empty?
532
+ stdout.puts "no recognizer proposals (#{corpus.observed_iri_count} observations scanned)"
533
+ return 0
534
+ end
535
+
536
+ proposals.each_with_index do |p, i|
537
+ stdout.puts if i > 0
538
+ stdout.puts "proposal: #{p.suggested_type} (#{p.prefix})"
539
+ stdout.puts " strategy: #{p.strategy}"
540
+ stdout.puts " coverage: #{format('%.2f', p.coverage)}"
541
+ stdout.puts " confidence: #{format('%.2f', p.confidence)}"
542
+ stdout.puts " observations: #{p.observation_count}"
543
+ stdout.puts " hosts: #{p.hosts.to_a.sort.join(', ')}"
544
+ stdout.puts " positions: #{p.positions.size}"
545
+ stdout.puts " samples: #{p.sample_values.first(3).join(', ')}"
546
+ end
547
+ 0
548
+ end
549
+
550
+ # --reinfer: drop the materialized views in the corpus and replay the
551
+ # source-IRI log through the current classifier + reducers. Prints a
552
+ # short before/after summary so the user can see what changed.
553
+ def cmd_reinfer(corpus, _opts)
554
+ return missing("--corpus") unless corpus
555
+
556
+ n = corpus.observed_iri_count
557
+ before = corpus.size
558
+ corpus.reinfer
559
+ after = corpus.size
560
+
561
+ stdout.puts "reinferred #{n} observation#{n == 1 ? '' : 's'}: " \
562
+ "#{before} → #{after} cluster#{after == 1 ? '' : 's'}"
563
+ 0
564
+ end
565
+
566
+ # `completion <shell>` — emit the bundled shell-completion script.
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).
571
+ COMPLETIONS_DIR = File.expand_path("../../completions", __dir__).freeze
572
+ COMPLETION_FILES = {
573
+ "bash" => File.join(COMPLETIONS_DIR, "iriq.bash"),
574
+ "zsh" => File.join(COMPLETIONS_DIR, "_iriq"),
575
+ }.freeze
576
+
577
+ def cmd_completion(args)
578
+ shell = args.first || default_shell
579
+ path = COMPLETION_FILES[shell]
580
+ unless path
581
+ return emit_error("unknown_shell", "unknown shell #{shell.inspect} (try bash or zsh)", 1)
582
+ end
583
+ stdout.write(File.read(path))
584
+ 0
585
+ end
586
+
587
+ def default_shell
588
+ shell = ENV["SHELL"].to_s
589
+ shell.empty? ? "bash" : File.basename(shell).sub(/\.exe\z/, "")
590
+ end
591
+
592
+ # --cross-host-shapes: list route shapes that recur across multiple
593
+ # hosts in the corpus. One block per shape in human mode, JSON array
594
+ # under --json. Tunable via --min-hosts (default 2).
595
+ def cmd_cross_host_shapes(corpus, opts)
596
+ return missing("--corpus") unless corpus
597
+
598
+ kwargs = {}
599
+ kwargs[:min_hosts] = opts[:min_hosts] if opts[:min_hosts]
600
+ shapes = corpus.cross_host_shapes(**kwargs)
601
+
602
+ if opts[:json]
603
+ stdout.puts JSON.generate(shapes.map(&:to_h))
604
+ return 0
605
+ end
606
+
607
+ if shapes.empty?
608
+ stdout.puts "no cross-host shapes (#{corpus.size} cluster#{corpus.size == 1 ? '' : 's'} scanned)"
609
+ return 0
610
+ end
611
+
612
+ shapes.each do |s|
613
+ host_list = s.hosts.to_a.sort.join(", ")
614
+ stdout.puts "#{s.shape} (#{s.host_count} host#{s.host_count == 1 ? '' : 's'}: #{host_list}) obs=#{s.observation_count}"
615
+ end
616
+ 0
617
+ end
618
+
278
619
  def missing(name)
279
- stderr.puts "iriq: missing argument <#{name}>"
280
- 1
620
+ emit_error("missing_argument", "missing argument <#{name}>", 1)
621
+ end
622
+
623
+ # Detect whether JSON output was requested by scanning raw argv. Used
624
+ # before option parsing completes (or when it fails) so errors can still
625
+ # honor --json. Handles bundled short flags like -nj.
626
+ def json_requested?(argv)
627
+ argv.any? do |a|
628
+ a == "--json" || a == "--ndjson" ||
629
+ (a.start_with?("-") && !a.start_with?("--") && a.match?(/[jJ]/))
630
+ end
631
+ end
632
+
633
+ # Emit an error to stderr and return its exit code. Under --json/--ndjson
634
+ # the error is a structured envelope ({"error":{"code","message"}}) so
635
+ # agents and pipelines get parseable output on the failure path; otherwise
636
+ # the plain "iriq: <human>" line (human defaults to "iriq: <message>").
637
+ def emit_error(code, message, exit_code, human: nil)
638
+ if @json
639
+ stderr.puts JSON.generate(error: { code: code, message: message })
640
+ else
641
+ stderr.puts(human || "iriq: #{message}")
642
+ end
643
+ exit_code
281
644
  end
282
645
 
283
646
  def read_input(path)
@@ -314,6 +677,19 @@ module Iriq
314
677
  }.reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
315
678
  end
316
679
 
680
+ # Emit a JSON payload to stdout. When --ndjson is set and the payload is
681
+ # an Array, write one object per line (newline-delimited JSON) instead of
682
+ # one wrapping array — friendlier for `jq -c`, streaming pipelines, and
683
+ # log ingest tools. Non-array payloads (single objects) emit the same
684
+ # under both flags.
685
+ def emit_json(payload, opts)
686
+ if opts[:ndjson] && payload.is_a?(Array)
687
+ payload.each { |item| stdout.puts JSON.generate(item) }
688
+ else
689
+ stdout.puts JSON.generate(payload)
690
+ end
691
+ end
692
+
317
693
  def emit_sections(data, sections)
318
694
  multi = sections.size > 1
319
695
  sections.each_with_index do |sec, i|
@@ -321,41 +697,115 @@ module Iriq
321
697
  stdout.puts "# #{sec}" if multi
322
698
  case sec
323
699
  when :parse then emit_parse_human(data[:parse])
700
+ when :canonical then stdout.puts data[:canonical]
324
701
  when :normalize then stdout.puts data[:normalize]
702
+ when :explain then emit_explain_human(data[:explain])
325
703
  end
326
704
  end
327
705
  end
328
706
 
707
+ # Render the trace hash as a vertically-aligned per-segment table.
708
+ # path rows first, then query rows.
709
+ def emit_explain_human(trace)
710
+ stdout.puts trace[:normalized]
711
+ emit_trace_section("path", trace[:path])
712
+ emit_trace_section("query", trace[:query]) if trace[:query]
713
+ end
714
+
715
+ def emit_trace_section(label, rows)
716
+ return if rows.nil? || rows.empty?
717
+
718
+ stdout.puts
719
+ stdout.puts "#{label}:"
720
+ name_width = rows.map { |r| trace_label(r).length }.max
721
+ type_width = rows.map { |r| r[:type].to_s.length }.max
722
+ out_width = rows.map { |r| r[:output].to_s.length }.max
723
+ rows.each do |r|
724
+ stdout.puts " #{trace_label(r).ljust(name_width)} #{r[:type].to_s.ljust(type_width)} #{r[:output].to_s.ljust(out_width)}#{format_notes(r[:notes])}"
725
+ end
726
+ end
727
+
728
+ def trace_label(row)
729
+ # Path rows have :value, query rows have :name=:value.
730
+ row[:name] ? "#{row[:name]}=#{row[:value]}" : row[:value].to_s
731
+ end
732
+
733
+ def format_notes(notes)
734
+ return "" if notes.nil? || notes.empty?
735
+ " (" + notes.join("; ") + ")"
736
+ end
737
+
738
+ # Render the compact identifier_hash. Keys/values are already filtered;
739
+ # array/hash values get .inspect, everything else .to_s.
329
740
  def emit_parse_human(h)
330
- stdout.puts "original: #{h[:original]}"
331
- stdout.puts "kind: #{h[:kind]}"
332
- stdout.puts "scheme: #{h[:scheme]}" if h[:scheme]
333
- stdout.puts "host: #{h[:host]}" if h[:host]
334
- stdout.puts "port: #{h[:port]}" if h[:port]
335
- stdout.puts "path_segments: #{h[:path_segments].inspect}" if h[:kind] == :url
336
- stdout.puts "query_params: #{h[:query_params].inspect}" if h[:query_params] && !h[:query_params].empty?
337
- stdout.puts "fragment: #{h[:fragment]}" if h[:fragment]
338
- stdout.puts "nss: #{h[:nss]}" if h[:nss]
339
- stdout.puts "canonical: #{h[:canonical]}"
741
+ h.each do |key, value|
742
+ rendered = value.is_a?(Array) || value.is_a?(Hash) ? value.inspect : value.to_s
743
+ stdout.puts "#{"#{key}:".ljust(15)}#{rendered}"
744
+ end
340
745
  end
341
746
 
342
747
  def emit_clusters(clusters, opts)
343
- 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] }
344
752
 
345
753
  if opts[:json]
346
- stdout.puts JSON.generate(sorted.map(&:to_h))
754
+ emit_json(sorted.map(&:to_h), opts)
347
755
  else
348
756
  sorted.each_with_index do |c, i|
349
757
  stdout.puts if i > 0
350
758
  host = c.host || "(urn)"
351
759
  shape = opts[:hints] ? c.shape : raw_shape_for(c)
352
760
  stdout.puts "[#{c.count}] #{host} #{shape}"
353
- c.examples.first(3).each { |e| stdout.puts " #{e.canonical}" }
354
- stdout.puts " + #{c.count - 3} more" if c.count > 3
761
+ examples = c.examples.first(3)
762
+ examples.each { |e| stdout.puts " #{e.canonical}" }
763
+ remaining = c.count - examples.size
764
+ stdout.puts " + #{remaining} more" if remaining.positive?
765
+ emit_param_summary(c)
766
+ end
767
+ end
768
+ end
769
+
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%)`
772
+ def emit_param_summary(cluster)
773
+ rows = cluster.param_summary
774
+ return if rows.empty?
775
+
776
+ width = rows.map { |r| r[:name].length }.max
777
+ rows.each do |r|
778
+ bits = ["#{r[:type]}"]
779
+ bits << "conf #{format_conf(r[:confidence])}" if r[:confidence]
780
+ if r[:min] && r[:max]
781
+ bits << format_range(r[:min], r[:max])
782
+ bits << "avg #{format_num(r[:avg])}" if r[:avg]
355
783
  end
784
+ bits << "(#{r[:cardinality]} distinct, #{format_pct(r[:presence])})"
785
+ stdout.puts " #{r[:name].to_s.ljust(width)} #{bits.join(' ')}"
356
786
  end
357
787
  end
358
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
+
794
+ def format_range(lo, hi)
795
+ "#{format_num(lo)}..#{format_num(hi)}"
796
+ end
797
+
798
+ def format_num(n)
799
+ return n.to_s if n.is_a?(Integer)
800
+ whole = n.to_i
801
+ return whole.to_s if whole == n
802
+ n.round(2).to_s
803
+ end
804
+
805
+ def format_pct(frac)
806
+ "#{(frac * 100).round}%"
807
+ end
808
+
359
809
  def raw_shape_for(cluster)
360
810
  example = cluster.examples.first
361
811
  return cluster.shape unless example
@@ -388,7 +838,9 @@ module Iriq
388
838
  end
389
839
 
390
840
  def top(hash)
391
- hash.sort_by { |_, n| -n }.first(TOP_N_STATS).to_h
841
+ # Lex tie-break on equal counts — keeps --stats output deterministic
842
+ # and Ruby ↔ Rust parity stable regardless of insertion order.
843
+ hash.sort_by { |k, n| [-n, k] }.first(TOP_N_STATS).to_h
392
844
  end
393
845
  end
394
846
  end