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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +110 -0
- data/README.md +259 -356
- data/completions/_iriq +54 -0
- data/completions/iriq.bash +70 -0
- data/iriq.gemspec +2 -2
- data/lib/iriq/cli.rb +502 -50
- data/lib/iriq/cluster.rb +332 -14
- data/lib/iriq/clusterer.rb +0 -11
- data/lib/iriq/corpus.rb +321 -38
- data/lib/iriq/cross_host_shape.rb +37 -0
- data/lib/iriq/event.rb +22 -0
- data/lib/iriq/evidence.rb +114 -0
- data/lib/iriq/explanation.rb +1 -1
- data/lib/iriq/identifier.rb +1 -1
- data/lib/iriq/normalizer.rb +71 -29
- data/lib/iriq/parser.rb +5 -1
- data/lib/iriq/path_shape.rb +30 -24
- data/lib/iriq/position.rb +75 -0
- data/lib/iriq/position_stats.rb +74 -8
- data/lib/iriq/recognizer.rb +54 -0
- data/lib/iriq/recognizer_proposal.rb +167 -0
- data/lib/iriq/recognizers/date.rb +53 -0
- data/lib/iriq/recognizers/integer.rb +37 -0
- data/lib/iriq/recognizers/uuid.rb +16 -0
- data/lib/iriq/reducer.rb +37 -0
- data/lib/iriq/registrable_domain.rb +56 -0
- data/lib/iriq/segment_classifier.rb +478 -23
- data/lib/iriq/segment_hints.rb +9 -0
- data/lib/iriq/shape.rb +106 -0
- data/lib/iriq/specificity.rb +35 -0
- data/lib/iriq/storage/memory.rb +83 -12
- data/lib/iriq/storage/sqlite.rb +279 -43
- data/lib/iriq/synthesized_recognizer.rb +56 -0
- data/lib/iriq/trace.rb +294 -0
- data/lib/iriq/version.rb +1 -1
- data/lib/iriq.rb +17 -0
- metadata +19 -6
- data/CLAUDE.md +0 -121
- data/Gemfile +0 -3
- data/Gemfile.lock +0 -103
- data/Makefile +0 -56
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
|
|
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
|
|
34
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
162
|
+
corpus.save(corpus_path) if corpus && corpus_path
|
|
95
163
|
code
|
|
96
164
|
rescue Iriq::ParseError => e
|
|
97
|
-
|
|
98
|
-
2
|
|
165
|
+
emit_error("parse_error", e.message, 2, human: "iriq: parse error: #{e.message}")
|
|
99
166
|
rescue OptionParser::ParseError => e
|
|
100
|
-
|
|
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:
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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[:
|
|
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
|
-
#
|
|
211
|
-
#
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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 "# #{
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
354
|
-
stdout.puts "
|
|
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
|
-
|
|
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
|