vicary 0.2.0 → 0.2.4
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/README.md +25 -4
- data/lib/vicary/candidates.rb +86 -5
- data/lib/vicary/census.rb +285 -0
- data/lib/vicary/conformance.rb +33 -9
- data/lib/vicary/corpus.rb +600 -0
- data/lib/vicary/gates.rb +499 -0
- data/lib/vicary/gazetteer.rb +28 -0
- data/lib/vicary/latency_baseline.rb +170 -0
- data/lib/vicary/structured.rb +93 -16
- data/lib/vicary/version.rb +1 -1
- data/lib/vicary.rb +5 -1
- metadata +6 -3
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Vicary
|
|
7
|
+
# Is this build slower than the last release, and is that a fair question here?
|
|
8
|
+
#
|
|
9
|
+
# The latency gate used to hold an absolute number — 10 ms — which is a claim
|
|
10
|
+
# about the machine as much as about the code. It passed on a laptop and failed
|
|
11
|
+
# on the CI runner enforcing it, so v0.2.3 published to PyPI and npm and was
|
|
12
|
+
# refused by RubyGems on the same commit. This gem is the one that caught it.
|
|
13
|
+
#
|
|
14
|
+
# What replaced it asks a relative question: is this port slower than it was at
|
|
15
|
+
# the last release, by more than the tolerance. That only means something
|
|
16
|
+
# between measurements taken on comparable hardware, so this module's real work
|
|
17
|
+
# is REFUSING to compare when they are not — a machine difference reported as a
|
|
18
|
+
# code regression is worse than no gate, because it trains the reader to ignore
|
|
19
|
+
# it.
|
|
20
|
+
#
|
|
21
|
+
# This port reaches its own verdict from the shared file. It does not read
|
|
22
|
+
# Python's answer.
|
|
23
|
+
module LatencyBaseline
|
|
24
|
+
BASELINE_FILENAME = "latency_baseline.json"
|
|
25
|
+
|
|
26
|
+
# Set by CI on the one matrix entry whose language version matches the
|
|
27
|
+
# recorded profile. Absent everywhere else on purpose: a developer's laptop
|
|
28
|
+
# measures the same commit two to three times faster than the runner, and
|
|
29
|
+
# comparing that against a runner baseline reports a phantom improvement.
|
|
30
|
+
PROFILE_ENV_VAR = "VICARY_LATENCY_PROFILE"
|
|
31
|
+
|
|
32
|
+
IMPLEMENTATION = "ruby"
|
|
33
|
+
|
|
34
|
+
DEFAULT_TOLERANCE_PCT = 8.0
|
|
35
|
+
|
|
36
|
+
# The gate's answer, and — when it declines — why.
|
|
37
|
+
Comparison = Struct.new(
|
|
38
|
+
:measured_ms, :baseline_ms, :regression_pct, :tolerance_pct,
|
|
39
|
+
:comparable, :reason,
|
|
40
|
+
keyword_init: true
|
|
41
|
+
) do
|
|
42
|
+
def holds?
|
|
43
|
+
return false unless comparable && !regression_pct.nil?
|
|
44
|
+
|
|
45
|
+
regression_pct <= tolerance_pct
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
def baseline_path(dir = nil)
|
|
51
|
+
root = dir || Conformance.directory
|
|
52
|
+
return nil if root.nil?
|
|
53
|
+
|
|
54
|
+
path = Pathname.new(root).join(BASELINE_FILENAME)
|
|
55
|
+
path.exist? ? path : nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def load(dir = nil)
|
|
59
|
+
path = baseline_path(dir)
|
|
60
|
+
return nil if path.nil?
|
|
61
|
+
|
|
62
|
+
JSON.parse(path.read)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# `major.minor` of the running Ruby, matching how the profile records it.
|
|
66
|
+
def language_version
|
|
67
|
+
RUBY_VERSION.split(".").first(2).join(".")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Compare +measured_ms+ against the recorded baseline for this port.
|
|
71
|
+
#
|
|
72
|
+
# Every reason below is a refusal to compare, not a failure to measure: the
|
|
73
|
+
# number was measured either way and is reported either way. What is
|
|
74
|
+
# withheld is the verdict, because the two sides would not be like for like.
|
|
75
|
+
def compare(measured_ms, corpus_id, dir: nil, implementation: IMPLEMENTATION,
|
|
76
|
+
observed_language_version: nil, profile_env: nil)
|
|
77
|
+
doc = load(dir)
|
|
78
|
+
tolerance = (doc && doc["tolerance_pct"] || DEFAULT_TOLERANCE_PCT).to_f
|
|
79
|
+
lang = observed_language_version || language_version
|
|
80
|
+
|
|
81
|
+
declined = lambda do |reason, baseline_ms = nil|
|
|
82
|
+
Comparison.new(measured_ms: measured_ms, baseline_ms: baseline_ms,
|
|
83
|
+
regression_pct: nil, tolerance_pct: tolerance,
|
|
84
|
+
comparable: false, reason: reason)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
return declined.call("no #{BASELINE_FILENAME} in this checkout") if doc.nil?
|
|
88
|
+
|
|
89
|
+
profile = doc["profile"] || {}
|
|
90
|
+
want_profile = profile["id"]
|
|
91
|
+
have_profile = (profile_env || ENV[PROFILE_ENV_VAR] || "").strip
|
|
92
|
+
if have_profile.empty?
|
|
93
|
+
return declined.call(
|
|
94
|
+
"#{PROFILE_ENV_VAR} is unset, so this machine does not claim to be " \
|
|
95
|
+
"#{want_profile.inspect}; the baseline was recorded there"
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
unless have_profile == want_profile
|
|
99
|
+
return declined.call(
|
|
100
|
+
"#{PROFILE_ENV_VAR}=#{have_profile.inspect} but the baseline was " \
|
|
101
|
+
"recorded on #{want_profile.inspect}"
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
want_lang = (profile["language_versions"] || {})[implementation]
|
|
106
|
+
if !want_lang.nil? && want_lang.to_s != lang
|
|
107
|
+
return declined.call(
|
|
108
|
+
"#{implementation} #{lang} is not the #{want_lang} the baseline was " \
|
|
109
|
+
"recorded on; interpreter versions differ by more than the bar"
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
want_corpus = doc["corpus"]
|
|
114
|
+
if !want_corpus.nil? && want_corpus != corpus_id
|
|
115
|
+
return declined.call(
|
|
116
|
+
"corpus #{corpus_id.inspect} is not the #{want_corpus.inspect} the " \
|
|
117
|
+
"baseline was recorded on; latency scales with essay length"
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
entry = (doc["implementations"] || {})[implementation] || {}
|
|
122
|
+
recorded = entry["pooled_median_ms"]
|
|
123
|
+
if recorded.nil?
|
|
124
|
+
return declined.call(
|
|
125
|
+
"no baseline recorded for #{implementation} yet — the next release " \
|
|
126
|
+
"records one"
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
recorded = recorded.to_f
|
|
131
|
+
if recorded <= 0
|
|
132
|
+
return declined.call(
|
|
133
|
+
"recorded baseline for #{implementation} is not positive", recorded
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
Comparison.new(
|
|
138
|
+
measured_ms: measured_ms, baseline_ms: recorded,
|
|
139
|
+
regression_pct: (measured_ms / recorded - 1.0) * 100.0,
|
|
140
|
+
tolerance_pct: tolerance, comparable: true, reason: nil
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def render(comparison)
|
|
145
|
+
c = comparison
|
|
146
|
+
unless c.comparable
|
|
147
|
+
return format("latency %.3f ms — NOT COMPARED against the last release: %s",
|
|
148
|
+
c.measured_ms, c.reason)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
sign = c.regression_pct >= 0 ? "+" : ""
|
|
152
|
+
format("latency %.3f ms vs %.3f ms at the last release — %s%.2f%% " \
|
|
153
|
+
"against a %d%% bar",
|
|
154
|
+
c.measured_ms, c.baseline_ms, sign, c.regression_pct, c.tolerance_pct)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The keyword arguments Gates.measure wants. Returns the *detail* rather
|
|
158
|
+
# than a value when the comparison was declined, so the gate reports NOT
|
|
159
|
+
# MEASURED with the reason attached instead of quietly passing.
|
|
160
|
+
def gate_fields(measured_ms, corpus_id, **opts)
|
|
161
|
+
c = compare(measured_ms, corpus_id, **opts)
|
|
162
|
+
if c.comparable
|
|
163
|
+
{ latency_regression_pct: c.regression_pct }
|
|
164
|
+
else
|
|
165
|
+
{ latency_regression_detail: render(c) }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
data/lib/vicary/structured.rb
CHANGED
|
@@ -19,11 +19,12 @@ module Vicary
|
|
|
19
19
|
#
|
|
20
20
|
# **Order is the contract, not an optimisation.** The first pattern to claim a
|
|
21
21
|
# span wins, and placeholder indices follow mint order, so reordering these
|
|
22
|
-
# tables changes the output bytes even when it changes no verdict.
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
22
|
+
# tables changes the output bytes even when it changes no verdict. EMAIL and URL
|
|
23
|
+
# run first, because a school-issued address and a profile URL *contain* the
|
|
24
|
+
# writer's name and both are anchored too tightly to take one out of prose;
|
|
25
|
+
# identity next (an address line can otherwise swallow a surname); SSN and CARD
|
|
26
|
+
# before the generic digit runs; ZIP and AGE last, because both are bare digits
|
|
27
|
+
# and would claim characters belonging to a phone, card or address.
|
|
27
28
|
#
|
|
28
29
|
# ## Regex dialect
|
|
29
30
|
#
|
|
@@ -141,13 +142,34 @@ module Vicary
|
|
|
141
142
|
# Date of birth, explicitly labelled.
|
|
142
143
|
DOB = %r{\b(?:date\s+of\s+birth|d\.?o\.?b\.?|born\s+on)\s*:?\s*\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b}i
|
|
143
144
|
|
|
144
|
-
#
|
|
145
|
+
# The two structured patterns that run BEFORE identity interpolation, because
|
|
146
|
+
# their match text can legitimately *contain* the writer's own name:
|
|
147
|
+
# `first.last@district.org` and a profile URL ending in a name slug. Identity
|
|
148
|
+
# interpolation is a literal-name substitution, so running it first left these
|
|
149
|
+
# shredded rather than masked — `{NAME_2}.{NAME_1}{USERNAME_1}.k12.oh.us`
|
|
150
|
+
# instead of `{EMAIL_1}`, with the domain tail surviving in the clear and the
|
|
151
|
+
# span unrestorable on the round trip.
|
|
152
|
+
#
|
|
153
|
+
# Putting them first is safe in the direction that matters, and that asymmetry
|
|
154
|
+
# is the whole argument. Both are anchored on structure a name cannot supply —
|
|
155
|
+
# EMAIL needs an `@` and a dotted TLD, URL needs a scheme or a `www.` — so
|
|
156
|
+
# neither can reach into prose and take a bare surname out of it. Patterns
|
|
157
|
+
# that *could* still run after identity.
|
|
158
|
+
STRUCTURED_BEFORE_IDENTITY = [
|
|
159
|
+
["EMAIL", EMAIL],
|
|
160
|
+
["URL", URL_PATTERN],
|
|
161
|
+
].freeze
|
|
162
|
+
|
|
163
|
+
# (placeholder kind, pattern) in application order, running AFTER identity
|
|
164
|
+
# interpolation.
|
|
145
165
|
#
|
|
146
166
|
# CARD is handled separately because it needs the Luhn gate; ZIP and AGE run
|
|
147
167
|
# after it for the reason in the module docstring.
|
|
168
|
+
#
|
|
169
|
+
# Numbering is unaffected by which of these two tables a pattern sits in: the
|
|
170
|
+
# minter counts per kind, so `{EMAIL_1}` is the first email whether emails are
|
|
171
|
+
# matched before or after names.
|
|
148
172
|
STRUCTURED = [
|
|
149
|
-
["EMAIL", EMAIL],
|
|
150
|
-
["URL", URL_PATTERN],
|
|
151
173
|
["US_SOCIAL_SECURITY_NUMBER", SSN],
|
|
152
174
|
["IP_ADDRESS", IP],
|
|
153
175
|
["PHONE", PHONE],
|
|
@@ -182,6 +204,29 @@ module Vicary
|
|
|
182
204
|
# Delacroix-Whitfields' house").
|
|
183
205
|
POSSESSIVE_TAIL = "(?:['’]s|s['’])?"
|
|
184
206
|
|
|
207
|
+
# Is this single character a word character? Used to decide whether a literal
|
|
208
|
+
# needs a boundary lookaround on each end.
|
|
209
|
+
#
|
|
210
|
+
# A constant because it was previously built inside {literal_boundaries},
|
|
211
|
+
# which runs five times per redaction: at 50 redactions of one identity that
|
|
212
|
+
# was 250 regex compilations of a pattern that never varies.
|
|
213
|
+
#
|
|
214
|
+
# `Regexp#initialize` was 26% of this port's redaction CPU before this and
|
|
215
|
+
# the identity-pattern cache below; the fold cache in `gazetteer.rb` took the
|
|
216
|
+
# allocation half. Over ten runs of the 25-essay corpus gate, median p50 went
|
|
217
|
+
# 5.79 -> 3.10 ms and median p95 8.46 -> 4.43 ms.
|
|
218
|
+
#
|
|
219
|
+
# The number that mattered is the worst run, not the median: p95 ranged
|
|
220
|
+
# 7.75-11.35 ms before, so the 10 ms latency gate was failing outright about
|
|
221
|
+
# one run in ten and being read as a busy machine. It ranges 4.13-8.63 ms now.
|
|
222
|
+
# Three samples could not see that — the tail is one essay plus a GC pause,
|
|
223
|
+
# and it took ten runs per arm to separate the fix from the noise.
|
|
224
|
+
WORD_CHARACTER = /\A[#{W}]\z/.freeze
|
|
225
|
+
|
|
226
|
+
# How many identities' compiled patterns to keep. Small on purpose: the shape
|
|
227
|
+
# this serves is one student's essays in a row, not a working set.
|
|
228
|
+
IDENTITY_CACHE_MAX = 64
|
|
229
|
+
|
|
185
230
|
class << self
|
|
186
231
|
# Luhn checksum. Cuts the card pattern's false positives on long numbers.
|
|
187
232
|
def luhn_ok?(digits)
|
|
@@ -210,10 +255,9 @@ module Vicary
|
|
|
210
255
|
# ASCII-only where Python's is Unicode-aware; these agree with Python for
|
|
211
256
|
# an accented name.
|
|
212
257
|
def literal_boundaries(literal)
|
|
213
|
-
word = /\A[#{W}]\z/
|
|
214
258
|
[
|
|
215
|
-
literal[0].to_s.match?(
|
|
216
|
-
literal[-1].to_s.match?(
|
|
259
|
+
literal[0].to_s.match?(WORD_CHARACTER) ? "(?<![#{W}])" : "",
|
|
260
|
+
literal[-1].to_s.match?(WORD_CHARACTER) ? "(?![#{W}])" : "",
|
|
217
261
|
]
|
|
218
262
|
end
|
|
219
263
|
|
|
@@ -242,10 +286,39 @@ module Vicary
|
|
|
242
286
|
# of it, so "Jane Quincy-Adams" becomes one `{NAME}` rather than two
|
|
243
287
|
# adjacent placeholders.
|
|
244
288
|
def identity_patterns(identity)
|
|
245
|
-
out = []
|
|
246
289
|
first = identity_field(identity, :first_name)
|
|
247
290
|
last = identity_field(identity, :last_name)
|
|
248
291
|
school = identity_field(identity, :school_name)
|
|
292
|
+
extras = extra_names(identity).map { |raw| raw.to_s.strip }
|
|
293
|
+
|
|
294
|
+
# Keyed on the field VALUES, never on the identity object: a host that
|
|
295
|
+
# reuses one mutable struct per request would otherwise get the previous
|
|
296
|
+
# student's patterns, which is a privacy failure rather than a stale
|
|
297
|
+
# cache. Two identities with the same fields produce the same patterns by
|
|
298
|
+
# construction, so sharing an entry between them is exact.
|
|
299
|
+
key = [first, last, school, extras].freeze
|
|
300
|
+
cached = @identity_patterns_cache&.[](key)
|
|
301
|
+
return cached if cached
|
|
302
|
+
|
|
303
|
+
patterns = build_identity_patterns(first, last, school, extras)
|
|
304
|
+
|
|
305
|
+
# Bounded, and cleared wholesale rather than evicted one at a time. The
|
|
306
|
+
# win is a batch redacting many essays for ONE student, where the cache
|
|
307
|
+
# holds a single entry; a long-running host cycling through thousands
|
|
308
|
+
# gets the bound instead of a leak, and refilling it costs what building
|
|
309
|
+
# the patterns cost before this existed.
|
|
310
|
+
@identity_patterns_cache ||= {}
|
|
311
|
+
@identity_patterns_cache.clear if @identity_patterns_cache.size >= IDENTITY_CACHE_MAX
|
|
312
|
+
@identity_patterns_cache[key] = patterns
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Drop the memoized identity patterns. For tests that measure the build.
|
|
316
|
+
def reset_identity_cache
|
|
317
|
+
@identity_patterns_cache = nil
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def build_identity_patterns(first, last, school, extras)
|
|
321
|
+
out = []
|
|
249
322
|
|
|
250
323
|
if !first.empty? && !last.empty?
|
|
251
324
|
out << ["NAME", word_pattern("#{first} #{last}")]
|
|
@@ -255,8 +328,7 @@ module Vicary
|
|
|
255
328
|
out << ["NAME", word_pattern(last)] if !last.empty? && !AMBIGUOUS_SURNAMES.include?(last.downcase)
|
|
256
329
|
out << ["NAME", word_pattern(first)] if !first.empty? && !AMBIGUOUS_GIVEN_NAMES.include?(first.downcase)
|
|
257
330
|
|
|
258
|
-
|
|
259
|
-
extra = raw.to_s.strip
|
|
331
|
+
extras.each do |extra|
|
|
260
332
|
out << ["NAME", word_pattern(extra)] unless extra.empty?
|
|
261
333
|
end
|
|
262
334
|
|
|
@@ -286,10 +358,15 @@ module Vicary
|
|
|
286
358
|
masked = text
|
|
287
359
|
n = 0
|
|
288
360
|
|
|
289
|
-
# Identity patterns run
|
|
361
|
+
# Identity patterns run early: a name is the span most likely to be
|
|
290
362
|
# partially consumed by a looser pattern (an address line can swallow a
|
|
291
363
|
# surname), and masking it first makes that impossible.
|
|
292
|
-
|
|
364
|
+
#
|
|
365
|
+
# Early, not first. Email and URL precede it, because those two are the
|
|
366
|
+
# patterns whose own match text contains a name — see
|
|
367
|
+
# STRUCTURED_BEFORE_IDENTITY for why that direction is the safe one.
|
|
368
|
+
patterns = STRUCTURED_BEFORE_IDENTITY + identity_patterns(identity) + STRUCTURED
|
|
369
|
+
patterns.each do |kind, pattern|
|
|
293
370
|
masked, count = minter.substitute(kind, pattern, masked)
|
|
294
371
|
n += count
|
|
295
372
|
end
|
data/lib/vicary/version.rb
CHANGED
|
@@ -6,5 +6,5 @@ module Vicary
|
|
|
6
6
|
# Shared across all three front doors on purpose: one detector, one number. A
|
|
7
7
|
# gem 0.3.0 that corresponds to nothing on PyPI cannot be reasoned about, and
|
|
8
8
|
# the parity claim is between *versions*, not between package names.
|
|
9
|
-
VERSION = "0.2.
|
|
9
|
+
VERSION = "0.2.4"
|
|
10
10
|
end
|
data/lib/vicary.rb
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
#
|
|
18
18
|
# Three layers check that claim, and each catches what the one above it cannot:
|
|
19
19
|
#
|
|
20
|
-
# * `rake conformance` scores the
|
|
20
|
+
# * `rake conformance` scores the 54 frames — the final bar, and a coarse first
|
|
21
21
|
# one;
|
|
22
22
|
# * `rake test` runs `test/primitives_test.rb`, forty-odd primitives over the
|
|
23
23
|
# shared `primitives.json` corpus, which says *which brick is crooked*;
|
|
@@ -38,4 +38,8 @@ require_relative "vicary/minter"
|
|
|
38
38
|
require_relative "vicary/structured"
|
|
39
39
|
require_relative "vicary/candidates"
|
|
40
40
|
require_relative "vicary/conformance"
|
|
41
|
+
require_relative "vicary/gates"
|
|
42
|
+
require_relative "vicary/census"
|
|
43
|
+
require_relative "vicary/corpus"
|
|
44
|
+
require_relative "vicary/latency_baseline"
|
|
41
45
|
require_relative "vicary/redact"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: vicary
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Blake Thomas
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: |
|
|
14
14
|
Finds the names a student writes about — classmates, teachers, relatives — and
|
|
@@ -29,8 +29,12 @@ files:
|
|
|
29
29
|
- lib/vicary.rb
|
|
30
30
|
- lib/vicary/asset.rb
|
|
31
31
|
- lib/vicary/candidates.rb
|
|
32
|
+
- lib/vicary/census.rb
|
|
32
33
|
- lib/vicary/conformance.rb
|
|
34
|
+
- lib/vicary/corpus.rb
|
|
35
|
+
- lib/vicary/gates.rb
|
|
33
36
|
- lib/vicary/gazetteer.rb
|
|
37
|
+
- lib/vicary/latency_baseline.rb
|
|
34
38
|
- lib/vicary/lexicon.rb
|
|
35
39
|
- lib/vicary/minter.rb
|
|
36
40
|
- lib/vicary/redact.rb
|
|
@@ -41,7 +45,6 @@ licenses:
|
|
|
41
45
|
- MIT
|
|
42
46
|
metadata:
|
|
43
47
|
homepage_uri: https://github.com/bwthomas/vicary
|
|
44
|
-
source_code_uri: https://github.com/bwthomas/vicary
|
|
45
48
|
changelog_uri: https://github.com/bwthomas/vicary/blob/main/CHANGELOG.md
|
|
46
49
|
bug_tracker_uri: https://github.com/bwthomas/vicary/issues
|
|
47
50
|
post_install_message:
|