okf 1.13.0 → 2.0.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 +195 -0
- data/README.md +25 -6
- data/lib/okf/bundle/folder.rb +27 -1
- data/lib/okf/bundle/graph.rb +12 -3
- data/lib/okf/bundle/linter.rb +470 -47
- data/lib/okf/bundle/reader.rb +10 -4
- data/lib/okf/bundle/references.rb +111 -0
- data/lib/okf/bundle/row_filter.rb +53 -0
- data/lib/okf/bundle/search.rb +20 -2
- data/lib/okf/bundle/validator/result.rb +6 -3
- data/lib/okf/bundle/validator.rb +267 -26
- data/lib/okf/bundle/writer.rb +1 -1
- data/lib/okf/bundle.rb +105 -4
- data/lib/okf/cli/catalog.rb +1 -1
- data/lib/okf/cli/command.rb +27 -8
- data/lib/okf/cli/files.rb +1 -1
- data/lib/okf/cli/index.rb +1 -1
- data/lib/okf/cli/lint.rb +70 -12
- data/lib/okf/cli/references.rb +97 -0
- data/lib/okf/cli/search.rb +2 -1
- data/lib/okf/cli/stats.rb +3 -39
- data/lib/okf/cli/tags.rb +6 -43
- data/lib/okf/cli/types.rb +1 -1
- data/lib/okf/cli/validate.rb +3 -3
- data/lib/okf/cli.rb +4 -1
- data/lib/okf/concept.rb +362 -10
- data/lib/okf/markdown/citations.rb +41 -4
- data/lib/okf/markdown/frontmatter.rb +1 -1
- data/lib/okf/markdown/links.rb +67 -7
- data/lib/okf/render/graph/template.html.erb +173 -41
- data/lib/okf/render/graph.rb +11 -3
- data/lib/okf/server/app.rb +47 -15
- data/lib/okf/server/hub.rb +1 -1
- data/lib/okf/skill/SKILL.md +14 -12
- data/lib/okf/skill/playbooks/curate.md +8 -3
- data/lib/okf/skill/playbooks/doctor.md +3 -1
- data/lib/okf/skill/playbooks/maintain.md +7 -6
- data/lib/okf/skill/playbooks/menu.md +5 -4
- data/lib/okf/skill/playbooks/migrate.md +31 -8
- data/lib/okf/skill/playbooks/produce.md +16 -9
- data/lib/okf/skill/playbooks/search.md +2 -2
- data/lib/okf/skill/reference/SPEC.md +739 -187
- data/lib/okf/skill/reference/authoring.md +154 -35
- data/lib/okf/skill/reference/cli.md +155 -42
- data/lib/okf/skill/templates/attested-computation.md +41 -0
- data/lib/okf/skill/templates/concept.md +13 -6
- data/lib/okf/skill/templates/root-index.md +1 -1
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +22 -2
- metadata +5 -1
data/lib/okf/bundle/linter.rb
CHANGED
|
@@ -4,25 +4,50 @@ module OKF
|
|
|
4
4
|
class Bundle
|
|
5
5
|
# Lints a bundle for curation quality — the deterministic subset of the
|
|
6
6
|
# ingest → query → lint loop (overview.md): reachability, backlog, completeness,
|
|
7
|
-
# freshness, provenance, and hygiene. Pure — it reads
|
|
8
|
-
# entirely on the in-memory OKF::Bundle, mirroring
|
|
7
|
+
# freshness, provenance, attestation, migration, and hygiene. Pure — it reads
|
|
8
|
+
# nothing from disk and works entirely on the in-memory OKF::Bundle, mirroring
|
|
9
|
+
# OKF::Bundle::Validator.
|
|
9
10
|
#
|
|
10
|
-
# Unlike OKF::Bundle::Validator (the §
|
|
11
|
-
# links or missing optional fields), lint never rejects a bundle: it reports
|
|
11
|
+
# Unlike OKF::Bundle::Validator (the §11 conformance gate, which MUST NOT reject for
|
|
12
|
+
# broken links or missing optional fields), lint never rejects a bundle: it reports
|
|
12
13
|
# `:warn` and `:info` findings the spec marks as tolerable, and emits them as
|
|
13
14
|
# structured data (OKF::Bundle::Linter::Report) for a human or agent to act on. Contradictions
|
|
14
15
|
# and semantic staleness are NOT detected here — they need meaning, not structure;
|
|
15
16
|
# the JSON report is the substrate an agent consumes for those passes.
|
|
16
17
|
class Linter
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
# Severity is API: machine consumers gate edits and CI on `:warn` and drop
|
|
19
|
+
# `:info`, so an id changing level changes its behavior for them — this map
|
|
20
|
+
# is pinned by a test, and a new gateable state gets a flag (`--fail-on
|
|
21
|
+
# info`), never a severity promotion. Two calls worth their one sentence:
|
|
22
|
+
# `unattributed_claim` warns while its join-twin `unused_source` informs,
|
|
23
|
+
# because a dangling footnote misattributes a claim — a correctness defect —
|
|
24
|
+
# while an uncited source is only slack. And `expired` informs rather than
|
|
25
|
+
# warns: a `stale_after` passes on the calendar, not on a change, so a warn
|
|
26
|
+
# would fail a `--fail-on warn` gate on a morning nobody chose.
|
|
27
|
+
SEVERITIES = {
|
|
28
|
+
orphan: :warn, not_in_index: :warn, disconnected_component: :info, unlinked: :info,
|
|
29
|
+
missing_concept: :info, broken_index_entry: :warn,
|
|
30
|
+
stub: :info, missing_title: :info, missing_description: :info, missing_generated: :info,
|
|
31
|
+
expired: :info, stale: :warn,
|
|
32
|
+
uncited_external: :info, broken_source: :warn, unattributed_claim: :warn,
|
|
33
|
+
unused_source: :info, unprefixed_actor: :info,
|
|
34
|
+
incomplete_computation: :warn, broken_attestation_ref: :warn,
|
|
35
|
+
legacy_timestamp: :info, legacy_citations: :info,
|
|
36
|
+
duplicate_title: :info, unused_reference_def: :info, undefined_reference: :warn, self_link: :info,
|
|
37
|
+
log_order: :info
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# All checks, in display/registry order — derived from the severity map
|
|
41
|
+
# (insertion-ordered) rather than hand-listed twice: two parallel lists
|
|
42
|
+
# of the same 25 ids needed a test just to police their sync.
|
|
43
|
+
# `--only`/`--except` select from these.
|
|
44
|
+
CHECKS = SEVERITIES.keys.freeze
|
|
45
|
+
|
|
46
|
+
# An ATX heading naming the §10.3 computation section.
|
|
47
|
+
COMPUTATION_HEADING = /\A\#{1,6}\s+Computation\s*\z/i.freeze
|
|
48
|
+
|
|
49
|
+
# §7's three actor forms: `<producer>/<version>`, `human:<id>`, `process:<id>`.
|
|
50
|
+
ACTOR_FORMS = [ %r{\A\S+/\S+\z}, /\Ahuman:\S+\z/, /\Aprocess:\S+\z/ ].freeze
|
|
26
51
|
|
|
27
52
|
DEFAULT_MIN_BODY = 50
|
|
28
53
|
HUB_LIMIT = 5
|
|
@@ -31,10 +56,17 @@ module OKF
|
|
|
31
56
|
new(bundle, **options).call
|
|
32
57
|
end
|
|
33
58
|
|
|
34
|
-
|
|
59
|
+
# `today` is injected for the same reason `stale_before` is: the linter is
|
|
60
|
+
# pure and never reads the clock. Without it `expired` cannot know whether
|
|
61
|
+
# a `stale_after` has passed and does not run — and confesses, in
|
|
62
|
+
# stats[:skipped_checks], because a gate that is sometimes absent and does
|
|
63
|
+
# not say so converts "unchecked" into "checked and fine". The CLI always
|
|
64
|
+
# passes today; a library caller that wants the check passes `today:`.
|
|
65
|
+
def initialize(bundle, min_body: DEFAULT_MIN_BODY, stale_before: nil, today: nil, only: nil, except: nil)
|
|
35
66
|
@bundle = bundle
|
|
36
67
|
@min_body = min_body
|
|
37
68
|
@stale_before = stale_before
|
|
69
|
+
@today = coerce_today(today)
|
|
38
70
|
@only = only
|
|
39
71
|
@except = except
|
|
40
72
|
@report = Report.new
|
|
@@ -60,20 +92,65 @@ module OKF
|
|
|
60
92
|
@indexed_ids = indexed_by_dir.values.reduce(Set.new, :|)
|
|
61
93
|
end
|
|
62
94
|
|
|
95
|
+
# An id outside CHECKS refuses by name rather than intersecting to
|
|
96
|
+
# nothing: a caller pinned to a renamed id would otherwise get an empty,
|
|
97
|
+
# healthy report with skipped_checks: [] — "checked and fine" over a run
|
|
98
|
+
# that ran nothing, the exact silence skipped_checks exists to prevent.
|
|
99
|
+
# The CLI and the MCP shell validate first and exit 2; this is the same
|
|
100
|
+
# refusal for the library caller who has no argv layer in front.
|
|
63
101
|
def selected_checks
|
|
102
|
+
unknown = (Array(@only) + Array(@except)).map(&:to_sym) - CHECKS
|
|
103
|
+
raise ArgumentError, "unknown check(s): #{unknown.uniq.join(", ")} (checks: #{CHECKS.join(", ")})" unless unknown.empty?
|
|
104
|
+
|
|
64
105
|
checks = CHECKS
|
|
65
106
|
checks &= Array(@only).map(&:to_sym) if @only
|
|
66
107
|
checks -= Array(@except).map(&:to_sym) if @except
|
|
67
108
|
checks
|
|
68
109
|
end
|
|
69
110
|
|
|
111
|
+
# The documented contract is a Date, but the comment above *invites*
|
|
112
|
+
# library callers to pass one — so a Time or an ISO string coerces
|
|
113
|
+
# instead of detonating inside Concept#stale_on? halfway through a run,
|
|
114
|
+
# and anything else fails fast, here, with the contract named.
|
|
115
|
+
def coerce_today(value)
|
|
116
|
+
case value
|
|
117
|
+
when nil then nil
|
|
118
|
+
when DateTime, Time then value.to_date
|
|
119
|
+
when Date then value
|
|
120
|
+
when String then parse_today(value)
|
|
121
|
+
else raise ArgumentError, "today: must be a Date (got #{value.class})"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The rescue belongs to the parse alone. Wrapping the whole `case` put it
|
|
126
|
+
# around its own `else` too, so the refusal that names the class was
|
|
127
|
+
# caught and rewritten into the string message on its way out — a branch
|
|
128
|
+
# that could not be reached from any caller.
|
|
129
|
+
def parse_today(value)
|
|
130
|
+
raise ArgumentError, "not YYYY-MM-DD" unless value.match?(Concept::ISO_DATE)
|
|
131
|
+
|
|
132
|
+
Date.iso8601(value)
|
|
133
|
+
rescue ArgumentError
|
|
134
|
+
raise ArgumentError, "today: must be a Date or a YYYY-MM-DD string (got #{value.inspect})"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Every finding lands through here, so a check's severity has exactly one
|
|
138
|
+
# home — the SEVERITIES map — and cannot drift per call site.
|
|
139
|
+
def add(check, path, message, metric: nil)
|
|
140
|
+
if SEVERITIES.fetch(check) == :warn
|
|
141
|
+
@report.add_warning(check, path, message, metric: metric)
|
|
142
|
+
else
|
|
143
|
+
@report.add_info(check, path, message, metric: metric)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
70
147
|
# ── Reachability ─────────────────────────────────────────────────────────
|
|
71
148
|
|
|
72
149
|
def check_orphan
|
|
73
150
|
@concepts.each do |concept|
|
|
74
151
|
next if @inbound[concept.id].positive? || @indexed_ids.include?(concept.id)
|
|
75
152
|
|
|
76
|
-
|
|
153
|
+
add(:orphan, "#{concept.id}.md",
|
|
77
154
|
"unreachable: no inbound links and not listed in any index.md")
|
|
78
155
|
end
|
|
79
156
|
end
|
|
@@ -83,7 +160,7 @@ module OKF
|
|
|
83
160
|
concepts_in(dir).each do |concept|
|
|
84
161
|
next if listed.include?(concept.id)
|
|
85
162
|
|
|
86
|
-
|
|
163
|
+
add(:not_in_index, "#{concept.id}.md",
|
|
87
164
|
"not listed in its directory index (#{index_path_for(dir)})",
|
|
88
165
|
metric: { index: index_path_for(dir) })
|
|
89
166
|
end
|
|
@@ -101,7 +178,7 @@ module OKF
|
|
|
101
178
|
groups.each do |members|
|
|
102
179
|
next if members.equal?(main) || members.size < 2
|
|
103
180
|
|
|
104
|
-
|
|
181
|
+
add(:disconnected_component, nil,
|
|
105
182
|
"#{members.size} concepts form an island disconnected from the main graph",
|
|
106
183
|
metric: { size: members.size, members: members.sort })
|
|
107
184
|
end
|
|
@@ -118,7 +195,7 @@ module OKF
|
|
|
118
195
|
@concepts.each do |concept|
|
|
119
196
|
next unless loose.include?(concept.id)
|
|
120
197
|
|
|
121
|
-
|
|
198
|
+
add(:unlinked, "#{concept.id}.md",
|
|
122
199
|
"no cross-links (in or out); it floats in the graph")
|
|
123
200
|
end
|
|
124
201
|
end
|
|
@@ -142,7 +219,7 @@ module OKF
|
|
|
142
219
|
end
|
|
143
220
|
|
|
144
221
|
demand.sort_by { |target, entry| [ -entry[:references], target ] }.each do |target, entry|
|
|
145
|
-
|
|
222
|
+
add(:missing_concept, target,
|
|
146
223
|
"referenced by #{entry[:references]} link(s) across #{entry[:sources].size} concept(s) but does not exist",
|
|
147
224
|
metric: { references: entry[:references], sources: entry[:sources] })
|
|
148
225
|
end
|
|
@@ -154,7 +231,7 @@ module OKF
|
|
|
154
231
|
target = Markdown::Links.resolve(raw, from: path, bundle: @bundle.root)
|
|
155
232
|
next if target.nil? || @existing.include?(target)
|
|
156
233
|
|
|
157
|
-
|
|
234
|
+
add(:broken_index_entry, path,
|
|
158
235
|
"index links to missing concept `#{raw}`", metric: { target: target })
|
|
159
236
|
end
|
|
160
237
|
end
|
|
@@ -167,7 +244,7 @@ module OKF
|
|
|
167
244
|
length = concept.body.to_s.strip.length
|
|
168
245
|
next if length >= @min_body
|
|
169
246
|
|
|
170
|
-
|
|
247
|
+
add(:stub, "#{concept.id}.md",
|
|
171
248
|
"body is #{length} character(s) (under min-body #{@min_body})",
|
|
172
249
|
metric: { chars: length, min: @min_body })
|
|
173
250
|
end
|
|
@@ -181,63 +258,320 @@ module OKF
|
|
|
181
258
|
each_missing(:description, :missing_description, "description")
|
|
182
259
|
end
|
|
183
260
|
|
|
184
|
-
|
|
261
|
+
# Raw keys, not the accessor: a v0.1 `timestamp` is a recorded change time
|
|
262
|
+
# (§13.1 reads it), so the check is quiet on either spelling — it fires
|
|
263
|
+
# only when a document records nothing at all.
|
|
264
|
+
def check_missing_generated
|
|
185
265
|
@concepts.each do |concept|
|
|
186
|
-
next
|
|
266
|
+
next if concept.declared_generated? || concept.legacy_timestamp?
|
|
187
267
|
|
|
188
|
-
|
|
268
|
+
add(:missing_generated, "#{concept.id}.md", "missing recommended field: generated")
|
|
189
269
|
end
|
|
190
270
|
end
|
|
191
271
|
|
|
192
|
-
# ── Freshness
|
|
272
|
+
# ── Freshness ────────────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
# §5.5 — the author's own declared expiry, clock-gated: it runs only when
|
|
275
|
+
# the caller supplies a day to compare against, exactly the opt-in shape
|
|
276
|
+
# :stale has. Deliberately separate from :stale, which is a
|
|
277
|
+
# reader-supplied cutoff over `generated_at`: merging them would make a
|
|
278
|
+
# bundle's own contract depend on a CLI flag.
|
|
279
|
+
def check_expired
|
|
280
|
+
return if @today.nil?
|
|
281
|
+
|
|
282
|
+
@concepts.each do |concept|
|
|
283
|
+
next unless concept.stale_on?(@today)
|
|
284
|
+
|
|
285
|
+
add(:expired, "#{concept.id}.md",
|
|
286
|
+
"expired on #{concept.stale_after_date} (stale_after)",
|
|
287
|
+
metric: { stale_after: concept.stale_after_date.to_s,
|
|
288
|
+
days_past: (@today - concept.stale_after_date).to_i })
|
|
289
|
+
end
|
|
290
|
+
end
|
|
193
291
|
|
|
292
|
+
# Opt-in, and the operator's opinion rather than the bundle's: a cutoff
|
|
293
|
+
# supplied at the command line, compared against the last-change time.
|
|
194
294
|
def check_stale
|
|
195
295
|
return if @stale_before.nil?
|
|
196
296
|
|
|
197
297
|
@concepts.each do |concept|
|
|
198
|
-
at = parse_time(concept.
|
|
298
|
+
at = parse_time(concept.generated_at)
|
|
199
299
|
next if at.nil? || at >= @stale_before
|
|
200
300
|
|
|
201
|
-
|
|
202
|
-
"last updated #{concept.
|
|
203
|
-
metric: {
|
|
301
|
+
add(:stale, "#{concept.id}.md",
|
|
302
|
+
"last updated #{concept.generated_at}; older than cutoff #{@stale_before}",
|
|
303
|
+
metric: { generated_at: concept.generated_at.to_s, cutoff: @stale_before.to_s })
|
|
204
304
|
end
|
|
205
305
|
end
|
|
206
306
|
|
|
207
|
-
# ── Provenance (§
|
|
307
|
+
# ── Provenance (§5.1/§5.2) ───────────────────────────────────────────────────
|
|
208
308
|
|
|
309
|
+
# Asks `#sources` — the one place lint deliberately reads through the
|
|
310
|
+
# fallback-carrying accessor — so a v0.1 `# Citations` silences it and a
|
|
311
|
+
# migrated `sources` block silences it too. The section check beside it
|
|
312
|
+
# is not redundant: a prose-only `# Citations` ("See the Q3 report")
|
|
313
|
+
# yields no lifted mappings, and firing on it would fault a v0.1 concept
|
|
314
|
+
# that did record provenance — just not as links.
|
|
209
315
|
def check_uncited_external
|
|
210
316
|
@concepts.each do |concept|
|
|
317
|
+
# Memoized guards first: the link extraction is the expensive pass,
|
|
318
|
+
# and a sourced concept never needs it run.
|
|
319
|
+
next if concept.sources.any? || concept.legacy_citations?
|
|
320
|
+
|
|
211
321
|
externals = Markdown::Links.extract(concept.body).count { |raw| external?(raw) }
|
|
212
|
-
next if externals.zero?
|
|
322
|
+
next if externals.zero?
|
|
213
323
|
|
|
214
|
-
|
|
215
|
-
"body has external link(s) but no
|
|
324
|
+
add(:uncited_external, "#{concept.id}.md",
|
|
325
|
+
"body has external link(s) but no sources",
|
|
216
326
|
metric: { external_count: externals })
|
|
217
327
|
end
|
|
218
328
|
end
|
|
219
329
|
|
|
220
|
-
#
|
|
221
|
-
#
|
|
222
|
-
|
|
330
|
+
# The resolver is the discriminator: `Links.resolve` accepts only an
|
|
331
|
+
# in-bundle `.md` path, so URLs, scope descriptors, and non-`.md` assets
|
|
332
|
+
# (`references/attesters/revenue.py`) are exempt by construction — §5.1
|
|
333
|
+
# permits all three, and the Bundle does not index them.
|
|
334
|
+
#
|
|
335
|
+
# A leftover `# Citations` section is checked *beside* the native list,
|
|
336
|
+
# not only through the fallback: on a half-migrated concept the native
|
|
337
|
+
# entries win #sources, but §13.1 keeps the section readable, and its
|
|
338
|
+
# broken target was a warn before migration started — adopting `sources:`
|
|
339
|
+
# must not downgrade that gate to the info-level backlog checks. When the
|
|
340
|
+
# fallback did fire, the lifted entries and the section are the same
|
|
341
|
+
# values, deduplicated below.
|
|
342
|
+
def check_broken_source
|
|
223
343
|
@concepts.each do |concept|
|
|
224
|
-
|
|
344
|
+
resources = concept.sources.map { |source| source["resource"].to_s }
|
|
345
|
+
resources += concept.citation_entries.map { |entry| entry[:target].to_s } if concept.legacy_citations?
|
|
346
|
+
resources.uniq.each do |raw|
|
|
225
347
|
target = Markdown::Links.resolve(raw, from: concept.path, bundle: @bundle.root)
|
|
226
348
|
next if target.nil? || @existing.include?(target)
|
|
227
349
|
|
|
228
|
-
|
|
229
|
-
"
|
|
350
|
+
add(:broken_source, "#{concept.id}.md",
|
|
351
|
+
"source target `#{raw}` does not exist in the bundle", metric: { target: target })
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# §5.1's keyed attribution, checked in both directions. A dangling footnote
|
|
357
|
+
# misattributes a claim, so it warns; an uncited source (below) is only
|
|
358
|
+
# slack, so it informs. Two boundaries on the join: a concept with no
|
|
359
|
+
# sources[].id at all has not adopted keyed attribution — §5.1 does not
|
|
360
|
+
# reserve footnotes for it, and an ordinary GFM footnote is prose, not a
|
|
361
|
+
# fault — and the label↔id comparison folds case the way GFM resolves
|
|
362
|
+
# footnotes and reference_definitions already folds labels.
|
|
363
|
+
def check_unattributed_claim
|
|
364
|
+
@concepts.each do |concept|
|
|
365
|
+
ids = source_ids(concept).map(&:downcase)
|
|
366
|
+
next if ids.empty?
|
|
367
|
+
|
|
368
|
+
defined = Markdown::Links.footnote_definitions(concept.body).map(&:downcase)
|
|
369
|
+
footnote_labels(concept).each do |label|
|
|
370
|
+
next if ids.include?(label.downcase)
|
|
371
|
+
# A label with its own definition is an ordinary GFM content
|
|
372
|
+
# footnote — it renders complete, and §5.1 never reserves the
|
|
373
|
+
# label space for attribution. The dangling ones (no id, no
|
|
374
|
+
# definition) are the misattributions this check exists for.
|
|
375
|
+
next if defined.include?(label.downcase)
|
|
376
|
+
|
|
377
|
+
add(:unattributed_claim, "#{concept.id}.md",
|
|
378
|
+
"footnote `[^#{label}]` has no matching sources[].id", metric: { label: label })
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Sources with no `id` never participate — a lifted v0.1 citation has
|
|
384
|
+
# none, and it would be wrong to fault a bundle for not having adopted
|
|
385
|
+
# keyed attribution. The same case fold, and the same guard-first order,
|
|
386
|
+
# as its join-twin above: without them every concept of a non-adopting
|
|
387
|
+
# bundle paid a full body scan to compare against an empty set.
|
|
388
|
+
def check_unused_source
|
|
389
|
+
@concepts.each do |concept|
|
|
390
|
+
ids = source_ids(concept)
|
|
391
|
+
next if ids.empty?
|
|
392
|
+
|
|
393
|
+
labels = footnote_labels(concept).to_set(&:downcase)
|
|
394
|
+
ids.each do |id|
|
|
395
|
+
next if labels.include?(id.downcase)
|
|
396
|
+
|
|
397
|
+
add(:unused_source, "#{concept.id}.md",
|
|
398
|
+
"source `#{id}` is never cited by a footnote", metric: { id: id })
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# A missing `generated.by` is deliberately NOT a lint check: §5.2 marks
|
|
404
|
+
# `by` REQUIRED within the mapping, REQUIRED-within is the validator's
|
|
405
|
+
# side of the split, and the validator's :generated_by warning already
|
|
406
|
+
# reports it — a lint twin double-counted one defect with two severities,
|
|
407
|
+
# exactly what the parallel `verified[].by` family never did.
|
|
408
|
+
|
|
409
|
+
# The two fields §7 gives the actor convention to. `verified[].by` is
|
|
410
|
+
# where the misread bites — §5.3 derives the tier from it, so a bare
|
|
411
|
+
# `by: owner` silently reads as machine-confirmed. `generated.by` feeds
|
|
412
|
+
# no tier, but it is the field §7 names first, and a form nothing can
|
|
413
|
+
# classify leaves a provenance reader unable to tell a person from a
|
|
414
|
+
# process — so it earns the finding with its own consequence. Still not
|
|
415
|
+
# `sources[].author` (the SPEC's own examples use `team:<id>` there),
|
|
416
|
+
# and a *missing* `generated.by` stays the validator's warning.
|
|
417
|
+
# Info is load-bearing: it must inform, never block.
|
|
418
|
+
def check_unprefixed_actor
|
|
419
|
+
@concepts.each do |concept|
|
|
420
|
+
fields = []
|
|
421
|
+
generated_by = concept.generated && concept.generated["by"]
|
|
422
|
+
fields << [ "generated.by", generated_by, generated_consequence ]
|
|
423
|
+
concept.verified.each { |event| fields << [ "verified.by", event["by"], nil ] }
|
|
424
|
+
fields.each do |field, raw, consequence|
|
|
425
|
+
actor = raw.to_s.strip
|
|
426
|
+
next if actor.empty? || ACTOR_FORMS.any? { |form| actor.match?(form) }
|
|
427
|
+
|
|
428
|
+
add(:unprefixed_actor, "#{concept.id}.md",
|
|
429
|
+
"#{field} `#{actor}` matches none of §7's forms (`<producer>/<version>`, `human:<id>`, " \
|
|
430
|
+
"`process:<id>`)#{consequence || actor_consequence(actor)}",
|
|
431
|
+
metric: { by: actor, field: field })
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# §5.3 reads the tier off the `human:` prefix and nothing else, so an
|
|
437
|
+
# actor that carries it is already human-reviewed however malformed the
|
|
438
|
+
# id is — and the same report's trust stat says so. Telling that reader
|
|
439
|
+
# it "reads as machine-confirmed" contradicted the run they were reading
|
|
440
|
+
# and pointed the fix at a tier that was never wrong; the id is what
|
|
441
|
+
# needs the edit.
|
|
442
|
+
def actor_consequence(actor)
|
|
443
|
+
return " — the `human:` prefix already reads as human-reviewed, but the id is not a bare token" \
|
|
444
|
+
if actor.start_with?(Concept::HUMAN_ACTOR)
|
|
445
|
+
|
|
446
|
+
" and reads as machine-confirmed; use `human:<id>` if a person confirmed this"
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# §5.3 never reads `generated.by`, so unlike a verified actor nothing is
|
|
450
|
+
# misclassified — the cost is the audit trail: nobody downstream can say
|
|
451
|
+
# what kind of actor produced the text.
|
|
452
|
+
def generated_consequence
|
|
453
|
+
" — no trust tier reads it (§5.3 keys off verified), but a reader cannot tell a person from a process"
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# §9 describes the log as date-grouped entries, newest first — prose, not
|
|
457
|
+
# an RFC keyword, so disorder is curation slack rather than a §11 error:
|
|
458
|
+
# exactly lint's side of the split. Only shape-valid headings are
|
|
459
|
+
# compared; a malformed date is already the validator's error, and
|
|
460
|
+
# double-reporting it here as disorder would name one defect twice.
|
|
461
|
+
def check_log_order
|
|
462
|
+
@bundle.log_files.each do |path|
|
|
463
|
+
dates = @bundle.reserved_content(path).each_line
|
|
464
|
+
.select { |line| line.start_with?("## ") }
|
|
465
|
+
.map { |line| line.sub(/\A## /, "").strip }
|
|
466
|
+
.grep(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
467
|
+
next if dates == dates.sort.reverse
|
|
468
|
+
|
|
469
|
+
add(:log_order, path,
|
|
470
|
+
"date headings are not newest-first (§9): #{dates.join(", ")}",
|
|
471
|
+
metric: { dates: dates })
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# ── Attestation (§10) ────────────────────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
# `incomplete_computation` asks whether the contract *names* its
|
|
478
|
+
# computation; this asks whether what it names is there. A §10 contract
|
|
479
|
+
# is an instruction to run something, so a path resolving to nothing is
|
|
480
|
+
# not slack the way an uncited source is — it is a contract no consumer
|
|
481
|
+
# can follow, which is why it warns like `broken_source` rather than
|
|
482
|
+
# informing. Same exemption by construction as `broken_source`: Links.resolve
|
|
483
|
+
# yields nil for a URL or a non-`.md` asset, so a `references/*.sql`
|
|
484
|
+
# computation and an `https://` runbook are both silently fine.
|
|
485
|
+
def check_broken_attestation_ref
|
|
486
|
+
@concepts.each do |concept|
|
|
487
|
+
# Gated on the type, like check_incomplete_computation: §4.1 lets a
|
|
488
|
+
# producer put `computation:` or `executor:` on anything and mean
|
|
489
|
+
# their own thing by it, and §10 governs those keys only here. An
|
|
490
|
+
# ungated check would fail a --fail-on warn gate over a key it has
|
|
491
|
+
# no standing to read.
|
|
492
|
+
next unless concept.attested_computation?
|
|
493
|
+
|
|
494
|
+
attestation_refs(concept).each do |field, raw|
|
|
495
|
+
target = Markdown::Links.resolve(raw.to_s, from: concept.path, bundle: @bundle.root)
|
|
496
|
+
next if target.nil? || @existing.include?(target)
|
|
497
|
+
|
|
498
|
+
add(:broken_attestation_ref, "#{concept.id}.md",
|
|
499
|
+
"#{field} `#{raw}` does not exist in the bundle", metric: { field: field, target: target })
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# The §10 fields that name a file: `computation` (§10.3's path form) and
|
|
505
|
+
# the `resource` of `executor`/`attester` (§10.2), in the order a reader
|
|
506
|
+
# meets them so two dangling paths on one concept report predictably.
|
|
507
|
+
# #executor/#attester are nil unless the value is a mapping, so a
|
|
508
|
+
# malformed `executor: <path>` reads as absent here — its shape is the
|
|
509
|
+
# validator's finding, not a second report of the same defect.
|
|
510
|
+
def attestation_refs(concept)
|
|
511
|
+
[
|
|
512
|
+
[ "computation", concept.computation ],
|
|
513
|
+
[ "executor.resource", concept.executor && concept.executor["resource"] ],
|
|
514
|
+
[ "attester.resource", concept.attester && concept.attester["resource"] ]
|
|
515
|
+
]
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# §10.2/§10.3: the computation is provided exactly one way — a body
|
|
519
|
+
# `# Computation` fence *or* a `computation` path ("used instead of").
|
|
520
|
+
# Neither is a contract with nothing to run; both is two candidate
|
|
521
|
+
# computations and no rule for which one was sanctioned. Missing `runtime`
|
|
522
|
+
# is the validator's (shape/REQUIRED-within is its side of the split).
|
|
523
|
+
def check_incomplete_computation
|
|
524
|
+
@concepts.each do |concept|
|
|
525
|
+
next unless concept.attested_computation?
|
|
526
|
+
|
|
527
|
+
inline = computation_heading?(concept.body)
|
|
528
|
+
declared = !OKF.blank?(concept.computation)
|
|
529
|
+
if !inline && !declared
|
|
530
|
+
add(:incomplete_computation, "#{concept.id}.md",
|
|
531
|
+
"Attested Computation with no computation (neither a computation: path nor a # Computation section)",
|
|
532
|
+
metric: { provided: [] })
|
|
533
|
+
elsif inline && declared
|
|
534
|
+
add(:incomplete_computation, "#{concept.id}.md",
|
|
535
|
+
"Attested Computation provides its computation twice (§10.3: a computation: path is used " \
|
|
536
|
+
"instead of a # Computation section — keep one)",
|
|
537
|
+
metric: { provided: %w[computation body] })
|
|
230
538
|
end
|
|
231
539
|
end
|
|
232
540
|
end
|
|
233
541
|
|
|
542
|
+
# ── Migration (§13.1) ────────────────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
# What replaces a version gate. An operator running lint on a v0.1 bundle is
|
|
545
|
+
# *told*, in the tool that already exists for telling them things, and is
|
|
546
|
+
# never blocked — both are info, because §13 says the bundle is consumable
|
|
547
|
+
# forever and `--fail-on warn` must not turn red on it. A migration
|
|
548
|
+
# campaign gates explicitly: `--only legacy_timestamp,legacy_citations
|
|
549
|
+
# --fail-on info`.
|
|
550
|
+
#
|
|
551
|
+
# One finding per *bundle*, not per concept: a v0.1 bundle is v0.1 in every
|
|
552
|
+
# file, so a per-concept finding would print the same sentence once per
|
|
553
|
+
# document. `path` is nil and the members live in `metric`, exactly as
|
|
554
|
+
# :disconnected_component and :duplicate_title do.
|
|
555
|
+
def check_legacy_timestamp
|
|
556
|
+
report_legacy(:legacy_timestamp,
|
|
557
|
+
"the retired v0.1 `timestamp`; move the value under `generated: { by: <actor>, at: <the timestamp> }` " \
|
|
558
|
+
"— the actor is yours to supply, no tool can derive it", &:legacy_timestamp?)
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def check_legacy_citations
|
|
562
|
+
report_legacy(:legacy_citations,
|
|
563
|
+
"the retired v0.1 `# Citations` section; move provenance into a `sources:` list " \
|
|
564
|
+
"(`resource` plus optional `id`/`title`), key claims with `[^id]` footnotes, then delete the section",
|
|
565
|
+
&:legacy_citations?)
|
|
566
|
+
end
|
|
567
|
+
|
|
234
568
|
# ── Hygiene ────────────────────────────────────────────────────────────────
|
|
235
569
|
|
|
236
570
|
def check_duplicate_title
|
|
237
571
|
@concepts.group_by { |concept| concept.title.to_s.strip.downcase }.each do |key, members|
|
|
238
572
|
next if key.empty? || members.size < 2
|
|
239
573
|
|
|
240
|
-
|
|
574
|
+
add(:duplicate_title, nil,
|
|
241
575
|
"title #{members.first.title.inspect} used by #{members.size} concepts",
|
|
242
576
|
metric: { title: members.first.title, concepts: members.map(&:id).sort })
|
|
243
577
|
end
|
|
@@ -247,7 +581,7 @@ module OKF
|
|
|
247
581
|
@concepts.each do |concept|
|
|
248
582
|
defined = Markdown::Links.reference_definitions(concept.body).keys
|
|
249
583
|
(defined - reference_uses(concept.body)).each do |label|
|
|
250
|
-
|
|
584
|
+
add(:unused_reference_def, "#{concept.id}.md",
|
|
251
585
|
"reference definition `[#{label}]` is defined but never used", metric: { label: label })
|
|
252
586
|
end
|
|
253
587
|
end
|
|
@@ -257,7 +591,7 @@ module OKF
|
|
|
257
591
|
@concepts.each do |concept|
|
|
258
592
|
defined = Markdown::Links.reference_definitions(concept.body).keys
|
|
259
593
|
(reference_uses(concept.body) - defined).each do |label|
|
|
260
|
-
|
|
594
|
+
add(:undefined_reference, "#{concept.id}.md",
|
|
261
595
|
"reference-style link `[#{label}]` has no matching definition (an invisible broken link)",
|
|
262
596
|
metric: { label: label })
|
|
263
597
|
end
|
|
@@ -272,7 +606,7 @@ module OKF
|
|
|
272
606
|
end
|
|
273
607
|
next if count.zero?
|
|
274
608
|
|
|
275
|
-
|
|
609
|
+
add(:self_link, "#{concept.id}.md", "concept links to itself", metric: { count: count })
|
|
276
610
|
end
|
|
277
611
|
end
|
|
278
612
|
|
|
@@ -284,19 +618,48 @@ module OKF
|
|
|
284
618
|
@report.stat(:indexes, @bundle.index_files.size)
|
|
285
619
|
@report.stat(:logs, @bundle.log_files.size)
|
|
286
620
|
@report.stat(:skipped, @bundle.unparseable.size)
|
|
621
|
+
@report.stat(:skipped_checks, skipped_checks)
|
|
287
622
|
@report.stat(:orphans, count_findings(:orphan))
|
|
288
623
|
@report.stat(:loose, count_findings(:unlinked))
|
|
289
624
|
@report.stat(:stubs, count_findings(:stub))
|
|
290
625
|
@report.stat(:backlog, count_findings(:missing_concept))
|
|
291
626
|
@report.stat(:components, components.size)
|
|
292
627
|
@report.stat(:hubs, hubs)
|
|
293
|
-
# Through Graph.default, not a second `|| "Untyped"`: §
|
|
628
|
+
# Through Graph.default, not a second `|| "Untyped"`: §11.2 makes a
|
|
294
629
|
# whitespace-only type as non-conformant as a missing one, so the two must
|
|
295
630
|
# land in one bucket. Spelling the rule twice is how lint came to report a
|
|
296
631
|
# `" "` bucket that `types` and `graph` had never heard of — the same
|
|
297
632
|
# concepts counted by both verbs, into inventories that will not reconcile.
|
|
298
633
|
@report.stat(:types, frequency(@concepts.map { |c| Graph.default(c.type, "Untyped") }))
|
|
299
634
|
@report.stat(:tags, frequency(@concepts.flat_map { |c| c.tags.is_a?(Array) ? c.tags : [] }))
|
|
635
|
+
@report.stat(:trust, trust_distribution)
|
|
636
|
+
# Folded through the same rule --status narrows by, or the posture
|
|
637
|
+
# inventory cannot reconcile with the filter that acts on it.
|
|
638
|
+
@report.stat(:status, frequency(@concepts.map { |c| Concept.effective_status(c.declared_status) }))
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# The clock-gated checks that were selected and could not run — named,
|
|
642
|
+
# never silent: a gate that is sometimes absent and does not confess
|
|
643
|
+
# converts "unchecked" into "checked and fine". This also makes :stale
|
|
644
|
+
# honest; it has been silently opt-in since it shipped.
|
|
645
|
+
def skipped_checks
|
|
646
|
+
skipped = []
|
|
647
|
+
selected = selected_checks
|
|
648
|
+
skipped << :expired if selected.include?(:expired) && @today.nil?
|
|
649
|
+
skipped << :stale if selected.include?(:stale) && @stale_before.nil?
|
|
650
|
+
skipped
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# §5.3's three tiers, counted, in the wire spelling the rows use. A
|
|
654
|
+
# per-concept `unverified` *check* would fire on every concept of every
|
|
655
|
+
# bundle that has not adopted `verified`, so the posture is a distribution
|
|
656
|
+
# instead of noise. All three keys are always present, zeroes included: a
|
|
657
|
+
# missing key reads as "not measured" where 0 reads as "none", and only
|
|
658
|
+
# one of those is true.
|
|
659
|
+
def trust_distribution
|
|
660
|
+
counts = { "unverified" => 0, "machine-confirmed" => 0, "human-reviewed" => 0 }
|
|
661
|
+
@concepts.each { |concept| counts[concept.trust] += 1 }
|
|
662
|
+
counts
|
|
300
663
|
end
|
|
301
664
|
|
|
302
665
|
# ── helpers ────────────────────────────────────────────────────────────────
|
|
@@ -305,7 +668,7 @@ module OKF
|
|
|
305
668
|
@concepts.each do |concept|
|
|
306
669
|
next unless OKF.blank?(concept.public_send(field))
|
|
307
670
|
|
|
308
|
-
|
|
671
|
+
add(check, "#{concept.id}.md", "missing recommended field: #{label}")
|
|
309
672
|
end
|
|
310
673
|
end
|
|
311
674
|
|
|
@@ -382,14 +745,74 @@ module OKF
|
|
|
382
745
|
uses = []
|
|
383
746
|
Markdown::Links.each_prose_line(body.to_s) do |line|
|
|
384
747
|
line.scan(Markdown::Links::REFERENCE_LINK).each do |label, explicit|
|
|
385
|
-
|
|
748
|
+
key = (explicit.empty? ? label : explicit).strip.downcase
|
|
749
|
+
# A caret label is footnote space (§5.1) — the exclusion DEFINITION
|
|
750
|
+
# already makes. Adjacent footnotes (`[^a][^b]`) match the
|
|
751
|
+
# reference-link grammar, and counting them here turned a
|
|
752
|
+
# well-formed document into an undefined_reference warn.
|
|
753
|
+
uses << key unless key.start_with?("^")
|
|
386
754
|
end
|
|
387
755
|
end
|
|
388
756
|
uses.uniq
|
|
389
757
|
end
|
|
390
758
|
|
|
759
|
+
def source_ids(concept)
|
|
760
|
+
concept.sources.map { |source| source["id"].to_s.strip }.reject(&:empty?)
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
# One body scan per concept, however many joins ask — the two keyed-
|
|
764
|
+
# attribution checks read the same labels, and the scan is a real
|
|
765
|
+
# per-line pass (CODE_SPAN blanking included).
|
|
766
|
+
def footnote_labels(concept)
|
|
767
|
+
@footnote_labels ||= {}
|
|
768
|
+
# Keyed on the path, which is unique by construction — two files may
|
|
769
|
+
# pin the same custom `id`, and an id-keyed cache cross-contaminated
|
|
770
|
+
# their joins.
|
|
771
|
+
@footnote_labels[concept.path] ||= Markdown::Links.footnote_references(concept.body)
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
# Whether the body carries a §10.3 `# Computation` section, fence-aware.
|
|
775
|
+
# §10.3's inline form is "a single fenced code block in the body under
|
|
776
|
+
# `# Computation`" — so inline is proven by the fence, not the heading. A
|
|
777
|
+
# heading over prose used to count as provided, and a contract with
|
|
778
|
+
# nothing an executor could run lint'd clean. Walked raw (not through
|
|
779
|
+
# each_prose_line, which blanks fences): a fence line while the section
|
|
780
|
+
# is open is the answer, and a heading inside an earlier fence is text.
|
|
781
|
+
def computation_heading?(body)
|
|
782
|
+
in_fence = false
|
|
783
|
+
in_section = false
|
|
784
|
+
body.to_s.each_line do |line|
|
|
785
|
+
stripped = line.strip
|
|
786
|
+
if stripped.start_with?("```", "~~~")
|
|
787
|
+
return true if in_section && !in_fence
|
|
788
|
+
|
|
789
|
+
in_fence = !in_fence
|
|
790
|
+
next
|
|
791
|
+
end
|
|
792
|
+
next if in_fence
|
|
793
|
+
|
|
794
|
+
if COMPUTATION_HEADING.match?(stripped)
|
|
795
|
+
in_section = true
|
|
796
|
+
elsif in_section && stripped.match?(/\A\#{1,6}\s/)
|
|
797
|
+
in_section = false
|
|
798
|
+
end
|
|
799
|
+
end
|
|
800
|
+
false
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
# One bundle-level finding naming how many concepts carry a retired
|
|
804
|
+
# spelling, with the file list in the metric for whatever will rewrite them.
|
|
805
|
+
def report_legacy(check, description, &spelling)
|
|
806
|
+
members = @concepts.select(&spelling).map { |concept| "#{concept.id}.md" }
|
|
807
|
+
return if members.empty?
|
|
808
|
+
|
|
809
|
+
add(check, nil,
|
|
810
|
+
"#{members.size} concept(s) still use #{description}",
|
|
811
|
+
metric: { concepts: members.sort })
|
|
812
|
+
end
|
|
813
|
+
|
|
391
814
|
def external?(raw)
|
|
392
|
-
raw.match?(Markdown::Links::SCHEME) || raw.
|
|
815
|
+
raw.match?(Markdown::Links::SCHEME) || raw.match?(Markdown::Links::MAILTO)
|
|
393
816
|
end
|
|
394
817
|
|
|
395
818
|
def count_findings(check)
|