iriq 0.30.2 → 0.35.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 +125 -0
- data/README.md +254 -92
- data/completions/_iriq +2 -0
- data/completions/iriq.bash +1 -1
- data/iriq.gemspec +1 -1
- data/lib/iriq/cli.rb +241 -70
- data/lib/iriq/cluster.rb +76 -24
- data/lib/iriq/clusterer.rb +0 -11
- data/lib/iriq/corpus.rb +236 -119
- data/lib/iriq/errors.rb +9 -0
- data/lib/iriq/identifier.rb +1 -1
- data/lib/iriq/normalizer.rb +21 -18
- data/lib/iriq/observation.rb +11 -6
- data/lib/iriq/parser.rb +5 -1
- data/lib/iriq/position_evidence.rb +31 -0
- data/lib/iriq/position_stats.rb +6 -4
- data/lib/iriq/recognizer.rb +1 -1
- data/lib/iriq/recognizer_proposal.rb +15 -3
- data/lib/iriq/reducer.rb +1 -2
- data/lib/iriq/segment_classifier.rb +17 -6
- data/lib/iriq/specificity.rb +3 -3
- data/lib/iriq/storage/json.rb +21 -7
- data/lib/iriq/storage/memory.rb +52 -10
- data/lib/iriq/storage/sqlite.rb +351 -44
- data/lib/iriq/storage.rb +18 -0
- data/lib/iriq/trace.rb +29 -33
- data/lib/iriq/version.rb +1 -1
- data/lib/iriq.rb +1 -0
- metadata +3 -8
- data/CLAUDE.md +0 -208
- data/Gemfile +0 -3
- data/Gemfile.lock +0 -103
- data/Makefile +0 -113
- data/docs/ARCHITECTURE.md +0 -223
- data/docs/ROADMAP.md +0 -190
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]' \
|
data/completions/iriq.bash
CHANGED
|
@@ -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
|
|
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"
|
|
@@ -24,8 +25,8 @@ module Iriq
|
|
|
24
25
|
iriq [options] < text
|
|
25
26
|
iriq cluster [options] [file]
|
|
26
27
|
|
|
27
|
-
<input> may be an IRI, a file
|
|
28
|
-
text via stdin.
|
|
28
|
+
<input> may be an IRI, a file to extract IRIs from (an existing file wins
|
|
29
|
+
unless the argument contains ://), or piped text via stdin.
|
|
29
30
|
|
|
30
31
|
Sections (combine freely):
|
|
31
32
|
-n, --normalize Shape — variable parts become placeholders
|
|
@@ -33,32 +34,43 @@ module Iriq
|
|
|
33
34
|
-p, --parse Parsed fields
|
|
34
35
|
-e, --explain Annotated trace — per-segment notes about why
|
|
35
36
|
each placeholder / canonical value was chosen
|
|
37
|
+
(mechanical rules only, even with a corpus)
|
|
36
38
|
|
|
37
39
|
Corpus + stats:
|
|
38
|
-
--corpus PATH
|
|
39
|
-
|
|
40
|
+
--corpus PATH Use a specific corpus file (overrides the default).
|
|
41
|
+
Extension picks the backend: .db/.sqlite/.sqlite3
|
|
42
|
+
are SQLite; anything else is JSON.
|
|
43
|
+
-C, --no-corpus Disable corpus persistence for this invocation.
|
|
44
|
+
Same as IRIQ_NO_CORPUS=1 in the environment.
|
|
45
|
+
--reset Delete the corpus (default path or the one
|
|
46
|
+
resolved via --corpus / IRIQ_CORPUS), its SQLite
|
|
47
|
+
sidecars and JSON temp files, and exit.
|
|
40
48
|
--host MODE Host-keying strategy for clustering:
|
|
41
49
|
full (default), registrable (or reg) strips
|
|
42
|
-
subdomains, none ignores host entirely.
|
|
50
|
+
subdomains, none ignores host entirely. Keys
|
|
51
|
+
IRIs as they're observed (-C included) and on
|
|
52
|
+
--reinfer; existing clusters keep their keys.
|
|
43
53
|
--stats Print rolling aggregates
|
|
44
54
|
--reinfer Replay the source-IRI log through the current
|
|
45
55
|
classifier + reducers; rebuilds materialized
|
|
46
|
-
views from scratch.
|
|
56
|
+
views from scratch.
|
|
47
57
|
--propose-recognizers
|
|
48
58
|
Scan observed values for shape patterns that
|
|
49
59
|
recur enough to suggest a new Recognizer.
|
|
50
60
|
Combine with --json for structured output.
|
|
51
|
-
Requires --corpus.
|
|
52
61
|
--cross-host-shapes
|
|
53
62
|
List route shapes that recur across
|
|
54
63
|
multiple hosts. Combine with --min-hosts.
|
|
55
|
-
Requires --corpus.
|
|
56
64
|
--activate-above F With --propose-recognizers, promote every
|
|
57
65
|
proposal at or above CONFIDENCE F into a
|
|
58
66
|
live Recognizer on the corpus, then
|
|
59
67
|
reinfer. Confidence integrates coverage
|
|
60
68
|
and cross-host corroboration.
|
|
61
69
|
|
|
70
|
+
Environment:
|
|
71
|
+
IRIQ_CORPUS=PATH Set the corpus path (overrides the default).
|
|
72
|
+
IRIQ_NO_CORPUS=1 Disable the default corpus (equivalent to -C).
|
|
73
|
+
|
|
62
74
|
Thresholds (apply to --propose-recognizers / --cross-host-shapes):
|
|
63
75
|
--min-observations N proposal noise floor (default 20)
|
|
64
76
|
--min-coverage F proposal coverage floor (default 0.7)
|
|
@@ -70,18 +82,23 @@ module Iriq
|
|
|
70
82
|
-h, --help Show this message
|
|
71
83
|
-j, --json Emit JSON instead of human-readable output
|
|
72
84
|
-J, --ndjson Newline-delimited JSON (one object per line). Implies --json.
|
|
85
|
+
Streams per IRI only with -n/-p/-c/-e; alone, it
|
|
86
|
+
prints the URL list or clusters at end of input.
|
|
73
87
|
-N, --no-hints Use {integer} placeholders instead of {user_id}
|
|
74
88
|
--no-scheme-less Skip foo.com/path extraction (explicit-scheme only)
|
|
75
89
|
-V, --version Print version
|
|
76
90
|
|
|
77
91
|
Subcommands:
|
|
78
|
-
cluster [file]
|
|
92
|
+
cluster [file] Observe file (or stdin), then show every cluster
|
|
93
|
+
in the corpus (default view for ≥10 IRIs anyway)
|
|
94
|
+
completion <shell> Print shell completion script (bash | zsh)
|
|
79
95
|
|
|
80
96
|
Examples:
|
|
81
97
|
iriq foo.com/users/456
|
|
82
98
|
iriq -n https://foo.com/users/123
|
|
83
|
-
iriq
|
|
99
|
+
iriq access.log # extract URLs (request paths have no host)
|
|
84
100
|
cat README.md | iriq -n # one normalized URL per line
|
|
101
|
+
tail -f app.log | iriq -nJ # live stream → NDJSON per IRI
|
|
85
102
|
cat README.md | iriq --corpus c.json
|
|
86
103
|
TXT
|
|
87
104
|
|
|
@@ -114,18 +131,30 @@ module Iriq
|
|
|
114
131
|
explicit_cluster = (args.first == "cluster")
|
|
115
132
|
args.shift if explicit_cluster
|
|
116
133
|
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
positional_is_file = args.first && File.file?(args.first) && !parseable_iri?(args.first)
|
|
134
|
+
# A positional that names an existing file is read as a file, so
|
|
135
|
+
# `iriq access.log` works without ./ (it also parses as a host). An
|
|
136
|
+
# argument containing "://" is always an IRI.
|
|
137
|
+
positional_is_file = args.first && !args.first.include?("://") && File.file?(args.first)
|
|
122
138
|
|
|
123
139
|
batch_mode = explicit_cluster || positional_is_file ||
|
|
124
140
|
(args.empty? && piped_stdin?)
|
|
125
141
|
|
|
142
|
+
# --reset short-circuits: delete the resolved corpus file (+ sidecars)
|
|
143
|
+
# and exit. Resolves through the same precedence chain as the normal
|
|
144
|
+
# path so `--reset --corpus other.db` and `IRIQ_CORPUS=… --reset` Just Work.
|
|
145
|
+
if opts[:reset]
|
|
146
|
+
return cmd_reset(opts)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Before any corpus is opened, so a typo doesn't create one.
|
|
150
|
+
if (missing = missing_input_file(args.first, explicit_cluster))
|
|
151
|
+
raise InputError.new("file_not_found", "no such file: #{missing}")
|
|
152
|
+
end
|
|
153
|
+
|
|
126
154
|
return print_usage(stdout, 0) if args.empty? && !batch_mode && !opts[:reinfer] && !opts[:propose] && !opts[:cross_host_shapes]
|
|
127
155
|
|
|
128
|
-
|
|
156
|
+
corpus_path = resolve_corpus_path(opts)
|
|
157
|
+
corpus = corpus_path ? load_corpus(corpus_path, host_strategy: opts[:host_strategy], announce_create: true) : nil
|
|
129
158
|
|
|
130
159
|
code = if opts[:reinfer]
|
|
131
160
|
cmd_reinfer(corpus, opts)
|
|
@@ -141,12 +170,26 @@ module Iriq
|
|
|
141
170
|
cmd_summary(args, opts, corpus)
|
|
142
171
|
end
|
|
143
172
|
|
|
144
|
-
corpus.save(
|
|
173
|
+
corpus.save(corpus_path) if corpus && corpus_path
|
|
145
174
|
code
|
|
146
175
|
rescue Iriq::ParseError => e
|
|
147
176
|
emit_error("parse_error", e.message, 2, human: "iriq: parse error: #{e.message}")
|
|
148
177
|
rescue OptionParser::ParseError => e
|
|
149
178
|
emit_error("option_error", e.message, 1)
|
|
179
|
+
rescue InputError => e
|
|
180
|
+
emit_error(e.code, e.message, 1)
|
|
181
|
+
rescue Iriq::CorpusError => e
|
|
182
|
+
emit_error("corpus_error", e.message, 1)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Input iriq can't read. `code` is the JSON error envelope's code.
|
|
186
|
+
class InputError < Iriq::Error
|
|
187
|
+
attr_reader :code
|
|
188
|
+
|
|
189
|
+
def initialize(code, message)
|
|
190
|
+
super(message)
|
|
191
|
+
@code = code
|
|
192
|
+
end
|
|
150
193
|
end
|
|
151
194
|
|
|
152
195
|
def parseable_iri?(input)
|
|
@@ -158,6 +201,15 @@ module Iriq
|
|
|
158
201
|
|
|
159
202
|
private
|
|
160
203
|
|
|
204
|
+
# The argument iriq would have read as a file but can't find: anything
|
|
205
|
+
# after `cluster`, or a /, ./, ../ path that isn't an IRI.
|
|
206
|
+
def missing_input_file(arg, explicit_cluster)
|
|
207
|
+
return nil if arg.nil? || arg == "-" || File.file?(arg)
|
|
208
|
+
return arg if explicit_cluster
|
|
209
|
+
|
|
210
|
+
arg if arg.start_with?("/", "./", "../") && !parseable_iri?(arg)
|
|
211
|
+
end
|
|
212
|
+
|
|
161
213
|
def parse_options(argv)
|
|
162
214
|
opts = {
|
|
163
215
|
json: false,
|
|
@@ -167,6 +219,8 @@ module Iriq
|
|
|
167
219
|
hints: true,
|
|
168
220
|
sections: [],
|
|
169
221
|
corpus: nil,
|
|
222
|
+
no_corpus: false,
|
|
223
|
+
reset: false,
|
|
170
224
|
stats: false,
|
|
171
225
|
reinfer: false,
|
|
172
226
|
propose: false,
|
|
@@ -191,6 +245,8 @@ module Iriq
|
|
|
191
245
|
o.on("--[no-]hints") { |v| opts[:hints] = v }
|
|
192
246
|
o.on("-N") { opts[:hints] = false }
|
|
193
247
|
o.on("--corpus PATH") { |v| opts[:corpus] = v }
|
|
248
|
+
o.on("-C", "--no-corpus") { opts[:no_corpus] = true }
|
|
249
|
+
o.on("--reset") { opts[:reset] = true }
|
|
194
250
|
o.on("--host MODE") { |v| opts[:host_strategy] = host_strategy_arg(v) }
|
|
195
251
|
o.on("--stats") { opts[:stats] = true }
|
|
196
252
|
o.on("--reinfer") { opts[:reinfer] = true }
|
|
@@ -220,8 +276,95 @@ module Iriq
|
|
|
220
276
|
end
|
|
221
277
|
end
|
|
222
278
|
|
|
223
|
-
def load_corpus(path, host_strategy: :full)
|
|
224
|
-
|
|
279
|
+
def load_corpus(path, host_strategy: :full, announce_create: false)
|
|
280
|
+
creating = announce_create && !File.exist?(path)
|
|
281
|
+
if creating
|
|
282
|
+
begin
|
|
283
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
284
|
+
rescue SystemCallError => e
|
|
285
|
+
raise CorpusError, "corpus #{path}: #{Iriq.os_error_message(e)}"
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
corpus = Corpus.open(path, host_strategy: host_strategy)
|
|
289
|
+
# Only announce once the corpus actually exists — mkdir_p can succeed
|
|
290
|
+
# (e.g. the dir is there but read-only) while the open still fails.
|
|
291
|
+
stderr.puts "iriq: created corpus at #{path} (disable with --no-corpus or IRIQ_NO_CORPUS=1)" if creating
|
|
292
|
+
corpus
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Resolve the corpus file the CLI should use. Precedence:
|
|
296
|
+
# 1. --corpus PATH — explicit always wins (you asked for it)
|
|
297
|
+
# 2. --no-corpus / IRIQ_NO_CORPUS=1 — opt out of the default
|
|
298
|
+
# 3. IRIQ_CORPUS=PATH — env override of the default location
|
|
299
|
+
# 4. default_corpus_path — platform-aware location
|
|
300
|
+
def resolve_corpus_path(opts)
|
|
301
|
+
return opts[:corpus] if opts[:corpus]
|
|
302
|
+
return nil if opts[:no_corpus] || env_corpus_disabled?
|
|
303
|
+
|
|
304
|
+
env_path = ENV["IRIQ_CORPUS"].to_s
|
|
305
|
+
return env_path unless env_path.empty?
|
|
306
|
+
|
|
307
|
+
default_corpus_path
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Platform-aware default. XDG-honoring on Linux + BSD, Apple-style on
|
|
311
|
+
# macOS, %LOCALAPPDATA% on Windows. Same logic in Rust so both
|
|
312
|
+
# runtimes share the same default.db.
|
|
313
|
+
def default_corpus_path
|
|
314
|
+
base = if (xdg = ENV["XDG_DATA_HOME"].to_s) && !xdg.empty?
|
|
315
|
+
File.join(xdg, "iriq")
|
|
316
|
+
elsif RUBY_PLATFORM =~ /darwin/
|
|
317
|
+
File.expand_path("~/Library/Application Support/iriq")
|
|
318
|
+
elsif RUBY_PLATFORM =~ /mingw|mswin|cygwin/
|
|
319
|
+
File.join(ENV["LOCALAPPDATA"].to_s.empty? ? File.expand_path("~/AppData/Local") : ENV["LOCALAPPDATA"], "iriq")
|
|
320
|
+
else
|
|
321
|
+
File.expand_path("~/.local/share/iriq")
|
|
322
|
+
end
|
|
323
|
+
File.join(base, "default.db")
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def env_corpus_disabled?
|
|
327
|
+
v = ENV["IRIQ_NO_CORPUS"].to_s.downcase
|
|
328
|
+
!v.empty? && v != "0" && v != "false" && v != "no"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def cmd_reset(opts)
|
|
332
|
+
path = resolve_reset_path(opts)
|
|
333
|
+
unless path
|
|
334
|
+
return emit_error("missing_argument", "no corpus path to reset (use --corpus PATH or unset --no-corpus)", 1)
|
|
335
|
+
end
|
|
336
|
+
removed = []
|
|
337
|
+
[path, "#{path}-wal", "#{path}-shm", "#{path}.tmp", *json_temp_files(path)].each do |p|
|
|
338
|
+
if File.exist?(p)
|
|
339
|
+
File.delete(p)
|
|
340
|
+
removed << p
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
if removed.empty?
|
|
344
|
+
stderr.puts "iriq: no corpus to reset at #{path}"
|
|
345
|
+
else
|
|
346
|
+
stderr.puts "iriq: reset corpus at #{path}"
|
|
347
|
+
end
|
|
348
|
+
0
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# The JSON writer's per-save temp files, PATH.<pid>.<n>.tmp
|
|
352
|
+
# (Storage.write_atomically); a save killed before its rename leaves one.
|
|
353
|
+
def json_temp_files(path)
|
|
354
|
+
dir = File.dirname(path)
|
|
355
|
+
return [] unless Dir.exist?(dir)
|
|
356
|
+
|
|
357
|
+
pattern = /\A#{Regexp.escape(File.basename(path))}\.\d+\.\d+\.tmp\z/
|
|
358
|
+
Dir.children(dir).grep(pattern).map { |name| File.join(dir, name) }
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# --reset honors --corpus / IRIQ_CORPUS even when --no-corpus is set —
|
|
362
|
+
# the user is explicitly addressing a stored file, not the runtime state.
|
|
363
|
+
def resolve_reset_path(opts)
|
|
364
|
+
return opts[:corpus] if opts[:corpus]
|
|
365
|
+
env_path = ENV["IRIQ_CORPUS"].to_s
|
|
366
|
+
return env_path unless env_path.empty?
|
|
367
|
+
default_corpus_path
|
|
225
368
|
end
|
|
226
369
|
|
|
227
370
|
# Accept `--host=reg` as a short alias for the `registrable` mode.
|
|
@@ -231,7 +374,9 @@ module Iriq
|
|
|
231
374
|
|
|
232
375
|
def host_strategy_arg(value)
|
|
233
376
|
mode = HOST_STRATEGY_ALIASES[value.to_s.downcase]
|
|
234
|
-
|
|
377
|
+
# OptionParser prefixes the switch itself ("--host bogus" / "--host=bogus"),
|
|
378
|
+
# so the message carries only the accepted modes.
|
|
379
|
+
raise OptionParser::InvalidArgument.new(value, "(expected full|registrable|reg|none)") unless mode
|
|
235
380
|
|
|
236
381
|
mode
|
|
237
382
|
end
|
|
@@ -255,9 +400,7 @@ module Iriq
|
|
|
255
400
|
data = {}
|
|
256
401
|
data[:parse] = identifier_hash(iri) if sections.include?(:parse)
|
|
257
402
|
data[:canonical] = iri.canonical if sections.include?(:canonical)
|
|
258
|
-
if sections.include?(:normalize)
|
|
259
|
-
data[:normalize] = corpus ? corpus.normalize(iri) : Normalizer.normalize_identifier(iri, hints: opts[:hints])
|
|
260
|
-
end
|
|
403
|
+
data[:normalize] = normalize_section(iri, opts, corpus) if sections.include?(:normalize)
|
|
261
404
|
if sections.include?(:explain)
|
|
262
405
|
data[:explain] = Trace.for(iri, hints: opts[:hints])
|
|
263
406
|
end
|
|
@@ -276,20 +419,20 @@ module Iriq
|
|
|
276
419
|
# of URLs (one per line) and a file of prose with URLs both work. The
|
|
277
420
|
# corpus is ephemeral unless --corpus was given.
|
|
278
421
|
def cmd_batch(args, opts, corpus, explicit_cluster: false)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
#
|
|
282
|
-
#
|
|
283
|
-
#
|
|
284
|
-
#
|
|
285
|
-
# input, so they slurp.
|
|
422
|
+
# Per-IRI sections (-n/-p/-c/-e) stream: read input lazily, extract per
|
|
423
|
+
# line, and emit each IRI as it arrives (flushed for live
|
|
424
|
+
# `tail -f | iriq -n` pipelines). Each IRI renders as single-input would,
|
|
425
|
+
# so with -C there is no corpus, not a throwaway one. The aggregate views
|
|
426
|
+
# below — stats, clusters, the deduped URL list — need the whole input,
|
|
427
|
+
# so they slurp.
|
|
286
428
|
if opts[:sections].any?
|
|
287
429
|
emit_per_iri_sections(lazy_iris(args.first, opts), opts, corpus)
|
|
288
430
|
return 0
|
|
289
431
|
end
|
|
290
432
|
|
|
291
|
-
|
|
292
|
-
|
|
433
|
+
corpus ||= Corpus.new(host_strategy: opts[:host_strategy])
|
|
434
|
+
iris = extract_text(utf8!(read_text(args.first)), opts)
|
|
435
|
+
corpus.observe_all(iris)
|
|
293
436
|
|
|
294
437
|
if opts[:stats]
|
|
295
438
|
emit_stats(corpus, opts)
|
|
@@ -309,19 +452,42 @@ module Iriq
|
|
|
309
452
|
# (URL_CHAR_CLASS excludes whitespace) and `extract` does not dedup.
|
|
310
453
|
def lazy_iris(path, opts)
|
|
311
454
|
extractor = Extractor.new(scheme_less: opts[:scheme_less])
|
|
312
|
-
input_lines(path).lazy.flat_map { |line| extractor.extract(line) }
|
|
455
|
+
input_lines(path).lazy.flat_map { |line| extractor.extract(utf8!(line)) }
|
|
313
456
|
end
|
|
314
457
|
|
|
458
|
+
# Yields input lines as they arrive. Only the reads are guarded, so a
|
|
459
|
+
# failure in the caller's block isn't mistaken for a read error.
|
|
315
460
|
def input_lines(path)
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
461
|
+
return enum_for(:input_lines, path) unless block_given?
|
|
462
|
+
|
|
463
|
+
io = path.nil? || path == "-" ? stdin : read_guard { File.open(path) }
|
|
464
|
+
while (line = read_guard { io.gets })
|
|
465
|
+
yield line
|
|
320
466
|
end
|
|
467
|
+
ensure
|
|
468
|
+
io.close if io && !io.equal?(stdin)
|
|
321
469
|
end
|
|
322
470
|
|
|
323
|
-
#
|
|
324
|
-
#
|
|
471
|
+
# Input iriq can't read is the OS error in the Rust CLI's words,
|
|
472
|
+
# `iriq: Permission denied (os error 13)`, code read_error.
|
|
473
|
+
def read_guard
|
|
474
|
+
yield
|
|
475
|
+
rescue SystemCallError => e
|
|
476
|
+
raise InputError.new("read_error", Iriq.os_error_message(e))
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Input is UTF-8 regardless of locale; anything else is an error, not a
|
|
480
|
+
# backtrace. Message matches the Rust CLI's io::Error text.
|
|
481
|
+
def utf8!(text)
|
|
482
|
+
text = text.dup.force_encoding(Encoding::UTF_8)
|
|
483
|
+
raise InputError.new("invalid_utf8", "stream did not contain valid UTF-8") unless text.valid_encoding?
|
|
484
|
+
|
|
485
|
+
text
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Emit the requested sections (parse/canonical/normalize/explain) for each
|
|
489
|
+
# extracted IRI, observing each into `corpus` (when there is one) and then
|
|
490
|
+
# rendering it from the corpus as it now stands. `iris` may be a lazy
|
|
325
491
|
# enumerator; human and NDJSON output stream (flushed per IRI) while a single
|
|
326
492
|
# JSON array must be materialized. -n alone is the cleanest case: one line
|
|
327
493
|
# per URL.
|
|
@@ -331,14 +497,14 @@ module Iriq
|
|
|
331
497
|
# A wrapping JSON array can't be emitted incrementally — collect it
|
|
332
498
|
# (force the lazy enumerator to a real Array so emit_json sees an array).
|
|
333
499
|
if opts[:json] && !opts[:ndjson]
|
|
334
|
-
payloads = iris.map { |iri| corpus
|
|
500
|
+
payloads = iris.map { |iri| corpus&.observe(iri); section_payload(iri, sections, opts, corpus) }.to_a
|
|
335
501
|
out = sections.size == 1 ? payloads.map(&:values).flatten(1) : payloads
|
|
336
502
|
return emit_json(out, opts)
|
|
337
503
|
end
|
|
338
504
|
|
|
339
505
|
iris.each_with_index do |iri, i|
|
|
340
|
-
corpus
|
|
341
|
-
p = section_payload(iri, sections, opts)
|
|
506
|
+
corpus&.observe(iri)
|
|
507
|
+
p = section_payload(iri, sections, opts, corpus)
|
|
342
508
|
if opts[:ndjson]
|
|
343
509
|
items = sections.size == 1 ? p.values : [p]
|
|
344
510
|
items.each { |item| stdout.puts JSON.generate(item) }
|
|
@@ -354,6 +520,7 @@ module Iriq
|
|
|
354
520
|
when :parse then emit_parse_human(p[:parse])
|
|
355
521
|
when :canonical then stdout.puts p[:canonical]
|
|
356
522
|
when :normalize then stdout.puts p[:normalize]
|
|
523
|
+
when :explain then emit_explain_human(p[:explain])
|
|
357
524
|
end
|
|
358
525
|
end
|
|
359
526
|
end
|
|
@@ -361,14 +528,21 @@ module Iriq
|
|
|
361
528
|
end
|
|
362
529
|
end
|
|
363
530
|
|
|
364
|
-
|
|
531
|
+
# Key order is fixed (parse, canonical, normalize, explain) whatever the
|
|
532
|
+
# flag order, matching cmd_summary's multi-section JSON.
|
|
533
|
+
def section_payload(iri, sections, opts, corpus)
|
|
365
534
|
data = {}
|
|
366
|
-
data[:parse] = identifier_hash(iri)
|
|
367
|
-
data[:canonical] = iri.canonical
|
|
368
|
-
data[:normalize] =
|
|
535
|
+
data[:parse] = identifier_hash(iri) if sections.include?(:parse)
|
|
536
|
+
data[:canonical] = iri.canonical if sections.include?(:canonical)
|
|
537
|
+
data[:normalize] = normalize_section(iri, opts, corpus) if sections.include?(:normalize)
|
|
538
|
+
data[:explain] = Trace.for(iri, hints: opts[:hints]) if sections.include?(:explain)
|
|
369
539
|
data
|
|
370
540
|
end
|
|
371
541
|
|
|
542
|
+
def normalize_section(iri, opts, corpus)
|
|
543
|
+
corpus ? corpus.normalize(iri, hints: opts[:hints]) : Normalizer.normalize_identifier(iri, hints: opts[:hints])
|
|
544
|
+
end
|
|
545
|
+
|
|
372
546
|
def extract_text(text, opts)
|
|
373
547
|
Extractor.new(scheme_less: opts[:scheme_less]).extract(text)
|
|
374
548
|
end
|
|
@@ -406,7 +580,7 @@ module Iriq
|
|
|
406
580
|
# --propose-recognizers: scan observed values for prefix patterns
|
|
407
581
|
# that recur enough to suggest a new Recognizer. Prints one block
|
|
408
582
|
# per proposal in human mode, or a JSON array under --json. With
|
|
409
|
-
# --activate-above F, every proposal at or above
|
|
583
|
+
# --activate-above F, every proposal at or above confidence F is
|
|
410
584
|
# promoted to a live Recognizer on the corpus's classifier and the
|
|
411
585
|
# corpus reinfers to apply the new classifier to existing
|
|
412
586
|
# observations.
|
|
@@ -421,7 +595,7 @@ module Iriq
|
|
|
421
595
|
if opts[:activate_above]
|
|
422
596
|
activated = corpus.activate_proposals_above(opts[:activate_above], **kwargs)
|
|
423
597
|
if activated.empty?
|
|
424
|
-
stdout.puts "no proposals at or above
|
|
598
|
+
stdout.puts "no proposals at or above confidence #{opts[:activate_above]}"
|
|
425
599
|
else
|
|
426
600
|
activated.each do |r|
|
|
427
601
|
stdout.puts "activated: #{r.type} (#{r.prefix})"
|
|
@@ -473,9 +647,10 @@ module Iriq
|
|
|
473
647
|
end
|
|
474
648
|
|
|
475
649
|
# `completion <shell>` — emit the bundled shell-completion script.
|
|
476
|
-
# Scripts live in completions/{iriq.bash,_iriq} alongside the
|
|
477
|
-
# Homebrew installs them automatically, but the user can also do
|
|
478
|
-
# `source <(iriq completion bash)` in their shell rc.
|
|
650
|
+
# Scripts live in completions/{iriq.bash,_iriq} alongside the
|
|
651
|
+
# gem; Homebrew installs them automatically, but the user can also do
|
|
652
|
+
# `source <(iriq completion bash)` in their shell rc. The Rust CLI
|
|
653
|
+
# inlines the same scripts — keep them byte-identical (parity-tested).
|
|
479
654
|
COMPLETIONS_DIR = File.expand_path("../../completions", __dir__).freeze
|
|
480
655
|
COMPLETION_FILES = {
|
|
481
656
|
"bash" => File.join(COMPLETIONS_DIR, "iriq.bash"),
|
|
@@ -551,20 +726,8 @@ module Iriq
|
|
|
551
726
|
exit_code
|
|
552
727
|
end
|
|
553
728
|
|
|
554
|
-
def read_input(path)
|
|
555
|
-
if path.nil? || path == "-"
|
|
556
|
-
stdin.read.lines
|
|
557
|
-
else
|
|
558
|
-
File.readlines(path)
|
|
559
|
-
end
|
|
560
|
-
end
|
|
561
|
-
|
|
562
729
|
def read_text(path)
|
|
563
|
-
|
|
564
|
-
stdin.read
|
|
565
|
-
else
|
|
566
|
-
File.read(path)
|
|
567
|
-
end
|
|
730
|
+
read_guard { path.nil? || path == "-" ? stdin.read : File.read(path) }
|
|
568
731
|
end
|
|
569
732
|
|
|
570
733
|
# Compact identifier hash for parse output (both JSON and human). Drops
|
|
@@ -653,7 +816,10 @@ module Iriq
|
|
|
653
816
|
end
|
|
654
817
|
|
|
655
818
|
def emit_clusters(clusters, opts)
|
|
656
|
-
|
|
819
|
+
# Stable sort: equal-count clusters keep first-seen order. Ruby's
|
|
820
|
+
# sort_by is unstable, so ties need the explicit index tie-break to
|
|
821
|
+
# match the Rust CLI's (stable) sort.
|
|
822
|
+
sorted = clusters.sort_by.with_index { |c, i| [-c.count, i] }
|
|
657
823
|
|
|
658
824
|
if opts[:json]
|
|
659
825
|
emit_json(sorted.map(&:to_h), opts)
|
|
@@ -672,8 +838,8 @@ module Iriq
|
|
|
672
838
|
end
|
|
673
839
|
end
|
|
674
840
|
|
|
675
|
-
# One line per param: type, range (numeric), cardinality,
|
|
676
|
-
# `page integer 1..100 avg 50.5 (10 distinct, 100%)`
|
|
841
|
+
# One line per param: type, confidence, range (numeric), cardinality,
|
|
842
|
+
# presence. `page integer conf 0.87 1..100 avg 50.5 (10 distinct, 100%)`
|
|
677
843
|
def emit_param_summary(cluster)
|
|
678
844
|
rows = cluster.param_summary
|
|
679
845
|
return if rows.empty?
|
|
@@ -681,6 +847,7 @@ module Iriq
|
|
|
681
847
|
width = rows.map { |r| r[:name].length }.max
|
|
682
848
|
rows.each do |r|
|
|
683
849
|
bits = ["#{r[:type]}"]
|
|
850
|
+
bits << "conf #{format_conf(r[:confidence])}" if r[:confidence]
|
|
684
851
|
if r[:min] && r[:max]
|
|
685
852
|
bits << format_range(r[:min], r[:max])
|
|
686
853
|
bits << "avg #{format_num(r[:avg])}" if r[:avg]
|
|
@@ -690,6 +857,11 @@ module Iriq
|
|
|
690
857
|
end
|
|
691
858
|
end
|
|
692
859
|
|
|
860
|
+
# Two-decimal confidence, e.g. 0.87. Matches the Rust CLI formatting.
|
|
861
|
+
def format_conf(conf)
|
|
862
|
+
format("%.2f", conf)
|
|
863
|
+
end
|
|
864
|
+
|
|
693
865
|
def format_range(lo, hi)
|
|
694
866
|
"#{format_num(lo)}..#{format_num(hi)}"
|
|
695
867
|
end
|
|
@@ -737,9 +909,8 @@ module Iriq
|
|
|
737
909
|
end
|
|
738
910
|
|
|
739
911
|
def top(hash)
|
|
740
|
-
# Lex tie-break on equal counts —
|
|
741
|
-
#
|
|
742
|
-
# order). Keeps Ruby ↔ Go --stats parity stable.
|
|
912
|
+
# Lex tie-break on equal counts — keeps --stats output deterministic
|
|
913
|
+
# and Ruby ↔ Rust parity stable regardless of insertion order.
|
|
743
914
|
hash.sort_by { |k, n| [-n, k] }.first(TOP_N_STATS).to_h
|
|
744
915
|
end
|
|
745
916
|
end
|