okf 1.12.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +296 -0
  3. data/README.md +94 -466
  4. data/lib/okf/bundle/folder.rb +48 -3
  5. data/lib/okf/bundle/graph.rb +12 -3
  6. data/lib/okf/bundle/linter.rb +470 -47
  7. data/lib/okf/bundle/reader.rb +47 -18
  8. data/lib/okf/bundle/references.rb +111 -0
  9. data/lib/okf/bundle/row_filter.rb +53 -0
  10. data/lib/okf/bundle/search.rb +20 -2
  11. data/lib/okf/bundle/validator/result.rb +6 -3
  12. data/lib/okf/bundle/validator.rb +267 -26
  13. data/lib/okf/bundle/writer.rb +1 -1
  14. data/lib/okf/bundle.rb +124 -8
  15. data/lib/okf/cli/catalog.rb +2 -2
  16. data/lib/okf/cli/command.rb +93 -19
  17. data/lib/okf/cli/dirs.rb +1 -1
  18. data/lib/okf/cli/files.rb +2 -2
  19. data/lib/okf/cli/index.rb +3 -3
  20. data/lib/okf/cli/lint.rb +70 -12
  21. data/lib/okf/cli/references.rb +97 -0
  22. data/lib/okf/cli/search.rb +23 -8
  23. data/lib/okf/cli/stats.rb +3 -39
  24. data/lib/okf/cli/tags.rb +6 -43
  25. data/lib/okf/cli/types.rb +1 -1
  26. data/lib/okf/cli/validate.rb +3 -3
  27. data/lib/okf/cli.rb +4 -1
  28. data/lib/okf/concept/file.rb +17 -2
  29. data/lib/okf/concept.rb +362 -10
  30. data/lib/okf/markdown/citations.rb +41 -4
  31. data/lib/okf/markdown/frontmatter.rb +1 -1
  32. data/lib/okf/markdown/links.rb +67 -7
  33. data/lib/okf/path.rb +17 -3
  34. data/lib/okf/render/graph/template.html.erb +173 -41
  35. data/lib/okf/render/graph.rb +11 -3
  36. data/lib/okf/safe_read.rb +50 -0
  37. data/lib/okf/server/app.rb +47 -15
  38. data/lib/okf/server/hub.rb +1 -1
  39. data/lib/okf/skill/SKILL.md +14 -12
  40. data/lib/okf/skill/playbooks/curate.md +8 -3
  41. data/lib/okf/skill/playbooks/doctor.md +3 -1
  42. data/lib/okf/skill/playbooks/maintain.md +7 -6
  43. data/lib/okf/skill/playbooks/menu.md +5 -4
  44. data/lib/okf/skill/playbooks/migrate.md +31 -8
  45. data/lib/okf/skill/playbooks/produce.md +16 -9
  46. data/lib/okf/skill/playbooks/search.md +2 -2
  47. data/lib/okf/skill/reference/SPEC.md +739 -187
  48. data/lib/okf/skill/reference/authoring.md +154 -35
  49. data/lib/okf/skill/reference/cli.md +160 -44
  50. data/lib/okf/skill/templates/attested-computation.md +41 -0
  51. data/lib/okf/skill/templates/concept.md +13 -6
  52. data/lib/okf/skill/templates/root-index.md +1 -1
  53. data/lib/okf/version.rb +1 -1
  54. data/lib/okf.rb +23 -2
  55. metadata +7 -3
  56. data/CODE_OF_CONDUCT.md +0 -10
@@ -4,7 +4,7 @@ module OKF
4
4
  module Markdown
5
5
  # Markdown cross-link extraction and resolution — the single source of truth for
6
6
  # "which concepts does this body point at". Shared by OKF::Bundle::Graph (to build edges)
7
- # and OKF::Bundle::Validator (to warn on broken cross-links, §5.3), so both agree on what
7
+ # and OKF::Bundle::Validator (to warn on broken cross-links, §6.1), so both agree on what
8
8
  # counts as a link and where it resolves.
9
9
  module Links
10
10
  FENCE = /\A(```|~~~)/.freeze
@@ -14,13 +14,34 @@ module OKF
14
14
  # blanked before scanning — the inline analogue of FENCE.
15
15
  CODE_SPAN = /(`+).*?\1/.freeze
16
16
  # Inline link [text](target) or [text](target "title"); (?<!!) skips images.
17
- INLINE_LINK = /(?<!!)\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/.freeze
17
+ # group 1 = the link text, group 2 = the target: extract() wants only the
18
+ # target, Citations.entries carries the text into a source's `title` —
19
+ # one grammar, one regex.
20
+ INLINE_LINK = /(?<!!)\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/.freeze
18
21
  # Reference-style use: full [text][label] or collapsed [label][]; (?<!!) skips
19
22
  # images. group 1 = text/label, group 2 = the explicit label (empty if collapsed).
20
23
  REFERENCE_LINK = /(?<!!)\[([^\]]*)\]\[([^\]]*)\]/.freeze
21
24
  # Reference definition: [label]: target (optionally followed by a "title").
22
- DEFINITION = /\A[ \t]{0,3}\[([^\]]+)\]:[ \t]*(\S+)/.freeze
23
- SCHEME = %r{\A[a-z][a-z0-9+.-]*://}.freeze
25
+ # A label may not begin with `^`: that is a footnote definition (§5.1
26
+ # per-claim attribution), which would otherwise read as a reference
27
+ # definition nothing ever uses.
28
+ DEFINITION = /\A[ \t]{0,3}\[([^\^\]][^\]]*)\]:[ \t]*(\S+)/.freeze
29
+ # In-prose footnote reference [^label] — §5.1 joins it on a sources[].id;
30
+ # (?<!!) skips an image whose alt text happens to start with a caret.
31
+ FOOTNOTE_REFERENCE = /(?<!!)\[\^([^\]\s]+)\]/.freeze
32
+ # A footnote definition line ([^label]: prose) — never a reference.
33
+ FOOTNOTE_DEFINITION = /\A[ \t]{0,3}\[\^([^\]\s]+)\]:/.freeze
34
+ # The URI scheme grammar, in one source string the citation item
35
+ # regexes compose from — schemes are case-insensitive (RFC 3986), and
36
+ # answering the case question in two places had HTTP:// counted as
37
+ # provenance by Citations and as prose by this module.
38
+ SCHEME_NAME = "[a-zA-Z][a-zA-Z0-9+.-]*"
39
+ SCHEME = %r{\A#{SCHEME_NAME}://}.freeze
40
+ # mailto has no ://, so SCHEME cannot see it — and a scheme name is as
41
+ # case-insensitive here as everywhere else. This guard sat inline and
42
+ # case-sensitive in three places; `MAILTO:user@example.md` then passed
43
+ # both gates and resolved as a relative path.
44
+ MAILTO = /\Amailto:/i.freeze
24
45
 
25
46
  module_function
26
47
 
@@ -33,7 +54,7 @@ module OKF
33
54
  definitions = reference_definitions(text)
34
55
  found = []
35
56
  each_prose_line(text) do |line|
36
- found.concat(line.scan(INLINE_LINK).flatten)
57
+ line.scan(INLINE_LINK) { |_text, target| found << target }
37
58
  line.scan(REFERENCE_LINK).each do |label, explicit|
38
59
  key = (explicit.empty? ? label : explicit).strip.downcase
39
60
  target = definitions[key]
@@ -55,6 +76,34 @@ module OKF
55
76
  definitions
56
77
  end
57
78
 
79
+ # The distinct footnote labels referenced in prose (§5.1), in document
80
+ # order. A definition's own leading token is not a reference — a source
81
+ # cited only by `[^a]:` itself is still uncited — but the prose *after*
82
+ # it is prose like any other: `[^a]: see also [^b]` cites b, and skipping
83
+ # the whole line made that citation invisible to both provenance checks.
84
+ # Labels are deduplicated so one unmatched label yields one finding, not
85
+ # one per use.
86
+ def footnote_references(text)
87
+ labels = []
88
+ each_prose_line(text) do |line|
89
+ line.sub(FOOTNOTE_DEFINITION, "").scan(FOOTNOTE_REFERENCE) { |captures| labels << captures.first }
90
+ end
91
+ labels.uniq
92
+ end
93
+
94
+ # The footnote labels a body *defines* (`[^label]: …`), deduplicated. A
95
+ # label with a definition is a self-contained GFM content footnote;
96
+ # §5.1's keyed attribution never reserves the whole label space, so the
97
+ # provenance checks treat only undefined, unmatched labels as dangling.
98
+ def footnote_definitions(text)
99
+ labels = []
100
+ each_prose_line(text) do |line|
101
+ match = FOOTNOTE_DEFINITION.match(line)
102
+ labels << match[1] if match
103
+ end
104
+ labels.uniq
105
+ end
106
+
58
107
  # Yield each line outside a fenced code block, with inline code spans blanked.
59
108
  # Both exclusions mirror the rendered document: fenced and inline code are
60
109
  # literal text, so a link written inside them is not a cross-link.
@@ -79,9 +128,20 @@ module OKF
79
128
  # @param bundle [String] path to the bundle root
80
129
  def resolve(raw, from:, bundle:)
81
130
  target = raw.to_s.split("#", 2).first.to_s
82
- return nil if target.empty? || target.end_with?("/")
83
- return nil if target.match?(SCHEME) || target.start_with?("mailto:")
84
131
  return nil unless target.end_with?(".md")
132
+
133
+ resolve_path(raw, from: from, bundle: bundle)
134
+ end
135
+
136
+ # The path arithmetic under #resolve without its +.md+ gate — the resolver
137
+ # for §6.2's path-valued frontmatter fields (resource, sources[].resource,
138
+ # computation, executor.resource, attester.resource), which accept any
139
+ # file. Body cross-links stay .md-only through #resolve; keeping the gate
140
+ # there and not here is what stops the two rules from trading places.
141
+ def resolve_path(raw, from:, bundle:)
142
+ target = raw.to_s.split("#", 2).first.to_s
143
+ return nil if target.empty? || target.end_with?("/")
144
+ return nil if target.match?(SCHEME) || target.match?(MAILTO)
85
145
  return target.sub(%r{\A/+}, "") if target.start_with?("/")
86
146
 
87
147
  bundle_abs = File.expand_path(bundle)
data/lib/okf/path.rb CHANGED
@@ -24,11 +24,25 @@ module OKF
24
24
  relative = normalize_relative!(path)
25
25
  expanded_root = File.expand_path(root.to_s)
26
26
  expanded_path = File.expand_path(File.join(expanded_root, relative))
27
- unless expanded_path == expanded_root || expanded_path.start_with?("#{expanded_root}#{File::SEPARATOR}")
28
- raise Error, "path escapes bundle root"
29
- end
27
+ raise Error, "path escapes bundle root" unless under?(expanded_root, expanded_path)
30
28
 
31
29
  expanded_path
32
30
  end
31
+
32
+ # Is +path+ the root itself or a descendant of it? Pure string containment
33
+ # (no disk access), so it works on both lexical paths (File.expand_path) and
34
+ # symlink-resolved ones (File.realpath) — the shell resolves, this decides.
35
+ # Both arguments must already be absolute and normalized the same way.
36
+ #
37
+ # The prefix guards against a sibling passing as a child ("/foo" is not under
38
+ # "/food"), and reuses the root itself as the prefix when the root already
39
+ # ends in the separator — i.e. the filesystem root "/", whose children would
40
+ # otherwise be tested against "//" and every one rejected.
41
+ def self.under?(root, path)
42
+ return true if path == root
43
+
44
+ prefix = root.end_with?(File::SEPARATOR) ? root : "#{root}#{File::SEPARATOR}"
45
+ path.start_with?(prefix)
46
+ end
33
47
  end
34
48
  end
@@ -8,7 +8,7 @@
8
8
  <meta property="og:site_name" content="OKF">
9
9
  <meta property="og:title" content="<%= og_title %>">
10
10
  <meta property="og:description" content="<%= og_desc %>">
11
- <meta property="og:image" content="https://okfgem.com/og-demo-v3.png">
11
+ <meta property="og:image" content="https://okfgem.com/og-demo-v5.png">
12
12
  <meta property="og:image:type" content="image/png">
13
13
  <meta property="og:image:width" content="1200">
14
14
  <meta property="og:image:height" content="630">
@@ -16,7 +16,7 @@
16
16
  <meta name="twitter:card" content="summary_large_image">
17
17
  <meta name="twitter:title" content="<%= og_title %>">
18
18
  <meta name="twitter:description" content="<%= og_desc %>">
19
- <meta name="twitter:image" content="https://okfgem.com/og-demo-v3.png">
19
+ <meta name="twitter:image" content="https://okfgem.com/og-demo-v5.png">
20
20
  <meta name="twitter:image:alt" content="An interactive Open Knowledge Format knowledge graph.">
21
21
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='24' fill='%231a1a1a'/><polygon points='38,44 62,44 50,82' fill='%237a0a1e'/><polygon points='18,44 38,44 50,82' fill='%23a8112c'/><polygon points='62,44 82,44 50,82' fill='%23a8112c'/><polygon points='35,28 18,44 38,44' fill='%23dc1e3c'/><polygon points='65,28 82,44 62,44' fill='%23dc1e3c'/><polygon points='35,28 65,28 62,44 38,44' fill='%23f43f5e'/><polygon points='36,29 49,29 42,42' fill='%23fff' opacity='.38'/><polygon points='35,28 65,28 82,44 50,82 18,44' fill='none' stroke='%23ff6b7f' stroke-width='2' stroke-linejoin='round'/></svg>">
22
22
  <script>/* Resolve theme before first paint so there is no flash. */
@@ -246,7 +246,31 @@
246
246
  background:var(--line-2);border:1px solid var(--line);border-radius:7px;padding:3px 9px}
247
247
  .badge .dot{width:8px;height:8px;border-radius:50%}
248
248
  .status{font-size:10.5px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;padding:2px 7px;border-radius:5px;color:var(--muted);background:var(--line-2)}
249
+ /* §5.4's three, plus `shipped` — which this project used before the spec named
250
+ a vocabulary, and which stays styled because §4.1 lets a producer use any
251
+ value and requires a consumer to tolerate it. `stable` gets no rule because
252
+ it is never rendered: an absent status defaults to it, so badging it would
253
+ put a meaningless chip on every concept of every bundle. */
254
+ .status.draft{color:var(--warn);background:color-mix(in srgb,var(--warn) 14%,transparent)}
255
+ .status.deprecated{color:var(--accent-ink);background:color-mix(in srgb,var(--accent) 12%,transparent)}
249
256
  .status.shipped{color:var(--ok);background:color-mix(in srgb,var(--ok) 14%,transparent)}
257
+ /* §5.3's trust tiers — the third visual channel, after type colour and status.
258
+ Deliberately quieter than status: how much to believe a concept is a question
259
+ a reader asks *of* the card, where a deprecation is the card shouting. */
260
+ .tier{font-size:10.5px;font-weight:600;letter-spacing:.02em;padding:2px 7px;border-radius:5px;
261
+ color:var(--muted);background:var(--line-2);border:1px solid var(--line)}
262
+ .tier.machine-confirmed{color:var(--accent-ink);background:color-mix(in srgb,var(--accent) 12%,transparent);border-color:transparent}
263
+ .tier.human-reviewed{color:var(--ok);background:color-mix(in srgb,var(--ok) 14%,transparent);border-color:transparent}
264
+ /* Past the expiry its own author declared (§5.5), judged against the viewer's
265
+ clock. Warn-coloured wherever it appears — a card's meta row, the trust line. */
266
+ .stale{color:var(--warn)}
267
+ /* The inspector's trust line: the chips under a concept's description. */
268
+ .meta-trust{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:8px}
269
+ /* The two text members of the line, as opposed to the .tier/.status badges
270
+ that carry their own size. `.stale` has only a colour globally, so
271
+ without this it rendered at the panel's inherited size beside them. */
272
+ .meta-trust .gen,.meta-trust .stale{font-size:11.5px}
273
+ .meta-trust .gen{color:var(--faint)}
250
274
  .tag{background:var(--line-2);border:1px solid var(--line);border-radius:6px;padding:2px 8px;font-size:11px;color:var(--muted)}
251
275
 
252
276
  /* ── graph view ── */
@@ -1034,6 +1058,12 @@
1034
1058
  <div class="fgroup"><h4>Types</h4><div class="fchips" id="cat-ftypes"></div></div>
1035
1059
  <div class="fgroup"><h4>Dirs</h4><div class="fchips" id="cat-fdirs"></div></div>
1036
1060
  <div class="fgroup"><h4>Tags</h4><div class="fchips" id="cat-ftags"></div></div>
1061
+ <!-- §5.3/§5.4. Counted off the catalog rather than a fixed vocabulary,
1062
+ and hidden entirely when the bundle says nothing about either — a
1063
+ v0.1 bundle's whole trust posture is not worth a row of chips
1064
+ reading "unverified 9". -->
1065
+ <div class="fgroup" id="cat-fstatus-group" hidden><h4>Status</h4><div class="fchips" id="cat-fstatus"></div></div>
1066
+ <div class="fgroup" id="cat-ftrust-group" hidden><h4>Trust</h4><div class="fchips" id="cat-ftrust"></div></div>
1037
1067
  </div>
1038
1068
  </aside>
1039
1069
  </section>
@@ -1170,7 +1200,7 @@
1170
1200
  view switching full-text search
1171
1201
  catalog files
1172
1202
  tags stats
1173
- theme / fullscreen §6 map payload · §7 log payload
1203
+ theme / fullscreen §8 map payload · §9 log payload
1174
1204
  command palette the search bridge
1175
1205
  concept preview (touch) mobile drawer + sheet
1176
1206
  keyboard deep links · first visit
@@ -1224,18 +1254,22 @@ const SEARCH_ENDPOINT=<%= search_endpoint_json %>;
1224
1254
  cannot use — and the panel reads that null to explain itself. */
1225
1255
  const MANAGE_ROOT=<%= manage_root_json %>, MANAGE_TOKEN=<%= manage_token_json %>;
1226
1256
  /* null when served live (`okf server`) — the getters below fetch the endpoints
1227
- above. `okf render` injects a payload {catalog,index,logs,bodies}, and every
1228
- getter resolves from it instead (the /node/meta fragment derived from the
1229
- catalog), so one file needs no server and the endpoint consts above go inert. */
1257
+ above. `okf render` injects a payload {catalog,index,logs,bodies,sources}, and every
1258
+ getter resolves from it instead (the /node/meta trust line composed from the
1259
+ catalog row), so one file needs no server and the endpoint consts above go inert. */
1230
1260
  const EMBED=<%= embed_json %>;
1231
1261
  /* Full-text search state (MiniSearch, lazy — built on first search). One ranked
1232
1262
  index behind the search box for the graph, catalog and files views. It indexes
1233
- title/id/type/tags/description in every mode, plus each concept body wherever
1234
- the page already holds it: `okf render` bakes every body in, so a static file
1235
- searches bodies offline; the live server keeps bodies lazy, so its index stays
1236
- metadata-only until a backend body index arrives. Same 7.2.0 build and config
1237
- as the Ruby port, so a Ruby-built index and this one rank identically. */
1238
- const FT_FIELDS=['title','id','type','tags','description'].concat(EMBED?['body']:[]);
1263
+ title/id/type/tags/description in every mode, plus each concept body and its
1264
+ source text wherever the page already holds them: `okf render` bakes both in,
1265
+ so a static file searches them offline; the live server keeps bodies lazy and
1266
+ carries only a source *count* on a catalog row, so its index stays
1267
+ metadata-only until a backend body index arrives. The asymmetry is
1268
+ pre-existing and deliberate — spending body-sized bytes on every /catalog
1269
+ fetch to serve one view is the trade the payload already refuses for `body`
1270
+ (see .okf/capabilities/search.md). Same 7.2.0 build and config as the Ruby
1271
+ port, so a Ruby-built index and this one rank identically. */
1272
+ const FT_FIELDS=['title','id','type','tags','description'].concat(EMBED?['body','sources']:[]);
1239
1273
  let _MiniSearch=null,ftIndex=null,ftBuilding=null;
1240
1274
  const descOf={};
1241
1275
  const MIN=<%= OKF::Render::Graph::MIN_SIZE %>, MAX=<%= OKF::Render::Graph::MAX_SIZE %>;
@@ -1522,7 +1556,7 @@ interceptMdLinks(sideBody,()=>shownId,select,r=>r.kind==='index'?showDir(r.dir):
1522
1556
  interceptMdLinks(document.getElementById('fp-body'),()=>fileSel,id=>openFile(id),
1523
1557
  r=>openReserved(r.kind,r.kind==='index'?ixPathOf(r.dir):r.path));
1524
1558
  /* Clicking a folder node (tree mode) or an area box (cluster mode) opens that
1525
- directory's §6 index entry in the inspector: the authored map when one
1559
+ directory's §8 index entry in the inspector: the authored map when one
1526
1560
  exists, the synthesized listing when not. */
1527
1561
  function showDir(dir){getIndex().then(dirs=>{const d=dirs.find(x=>x.dir===dir);if(!d)return;
1528
1562
  const opened=openPanel();sideBody.scrollTop=0;
@@ -1541,19 +1575,86 @@ function showDir(dir){getIndex().then(dirs=>{const d=dirs.find(x=>x.dir===dir);i
1541
1575
  function showLog(path){LOGS=null;getLogs().then(logs=>{const l=logs.find(x=>x.path===path);if(!l)return;
1542
1576
  const opened=openPanel();sideBody.scrollTop=0;
1543
1577
  shownId=path.replace(/\.md$/,'');
1544
- sideBody.innerHTML=`<span class="type">update log · §7</span><h2 class="title">${esc(path)}</h2><div class="body" id="dir-body"></div>`;
1578
+ sideBody.innerHTML=`<span class="type">update log · §9</span><h2 class="title">${esc(path)}</h2><div class="body" id="dir-body"></div>`;
1545
1579
  renderMarkdown(document.getElementById('dir-body'),l.content||'');
1546
1580
  if(opened)requestAnimationFrame(()=>cy.resize());});}
1581
+ /* ── §5 trust: one composition, both modes ──
1582
+ The catalog row carries generated_at/generated_by, the raw `generated`
1583
+ boolean, the derived `trust` tier (hyphenated, the wire spelling), the
1584
+ declared `status` and `stale_after` — so every view can say how much to
1585
+ believe a concept. Three rules, shared by the cards and the trust line:
1586
+
1587
+ · status — only a NON-default one. §5.4 defaults an absent status to
1588
+ `stable`, so badging that would chip every concept everywhere.
1589
+ · trust — suppressed when unverified AND `generated` is undeclared: on a
1590
+ well-curated v0.1 bundle every concept has a lifted timestamp,
1591
+ so keying off generated_at would label every card "unverified".
1592
+ The raw boolean is the one predicate that gets this right.
1593
+ · expired — computed HERE, against the viewer's own today. A baked verdict
1594
+ is wrong from the next midnight and a static render lives for
1595
+ months; the page compares dates the way the CLI does. */
1596
+ const TODAY=()=>{const d=new Date();return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');};
1597
+ const isExpired=sa=>!!sa&&/^\d{4}-\d{2}-\d{2}$/.test(sa)&&TODAY()>=sa;
1598
+ /* One fold for every status keying — chips, facets, suppression — because the
1599
+ row carries the DECLARED value and the CLI folds case when it narrows: a
1600
+ `status: Stable` bundle otherwise grew duplicate facet chips filtering
1601
+ disjoint sets, and "Stable" cards wore a chip the default never earns. The
1602
+ raw spelling still renders as the chip's text; only keys fold. */
1603
+ const foldStatus=v=>{const s=String(v==null?'':v).trim().toLowerCase();return s||'stable';};
1604
+ /* The folded value is a filter key, not a class name: §4.1 lets a producer
1605
+ declare `In Review`, which folded straight into the attribute became
1606
+ `class="status in review"` — two classes a stylesheet may already own. One
1607
+ token for the class, the raw spelling still the chip's text. */
1608
+ const statusSlug=v=>foldStatus(v).replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'')||'stable';
1609
+ const statusChip=c=>foldStatus(c.status)!=='stable'?`<span class="status ${esc(statusSlug(c.status))}">${esc(c.status)}</span>`:'';
1610
+ /* An `unverified` tier is an answer, not an absence — but only once a concept
1611
+ declared `generated:`, since a v0.1 concept declaring neither would wear a
1612
+ chip about a family it never had. The catalog's trust facet gates on this
1613
+ same predicate: a tier shown on every card with no group to narrow on was
1614
+ the shape it took when the two were spelled separately.
1615
+
1616
+ The rule's home is Ruby — Concept.shows_trust?, which the server's
1617
+ /node/meta and okf-tui both ask. This is the client-side twin, unavoidable
1618
+ because it runs over baked rows with no Ruby to call; a change to one is a
1619
+ change to both, and render_test.rb pins them equal so the copy cannot drift
1620
+ silently. */
1621
+ const showsTrust=c=>!!(c.trust&&!(c.trust==='unverified'&&!c.generated));
1622
+ const trustChip=c=>showsTrust(c)?`<span class="tier ${esc(c.trust)}">${esc(c.trust)}</span>`:'';
1623
+ /* No `by` means v0.1 lifted this from a `timestamp`, which recorded no actor.
1624
+ Printing one would be the false provenance claim §5 exists to prevent. */
1625
+ const genChip=c=>c.generated_at?`<span class="mini">⌂ ${esc(c.generated_at)}${c.generated_by?' by '+esc(c.generated_by):''}</span>`:'';
1547
1626
  /* Two per-concept reads shared by the inspector and the files preview: the raw
1548
- markdown body (rendered client-side) and the description fragment. Live they
1549
- hit /node and /node/meta; under EMBED the body resolves from the baked payload
1550
- and the meta fragment is derived from the baked catalog escaped here the same
1551
- way /node/meta escapes it so the description lives in exactly one place.
1552
- Never memoized in server mode, so on-disk edits keep reflecting. */
1553
- const EMBED_DESC=EMBED?Object.fromEntries((EMBED.catalog||[]).map(c=>[c.id,c.description||''])):null;
1554
- const metaFragment=d=>(d==null||String(d).trim()==='')?'<span class="empty">no description</span>':esc(d);
1627
+ markdown body (rendered client-side) and the meta object {description,trust}.
1628
+ Live they hit /node and /node/meta (JSON); under EMBED both resolve from the
1629
+ baked payload — the trust fields read off the same catalog row the cards use,
1630
+ so the two modes compose one line from one ruleset. Never memoized in server
1631
+ mode, so on-disk edits keep reflecting. */
1632
+ const EMBED_ROW=EMBED?Object.fromEntries((EMBED.catalog||[]).map(c=>[c.id,c])):null;
1633
+ const rowTrust=c=>{if(!c)return null;const t={};
1634
+ if(showsTrust(c))t.tier=c.trust;
1635
+ if(c.generated_by)t.generated_by=c.generated_by;
1636
+ if(c.generated_at)t.generated_at=c.generated_at;
1637
+ if(c.status)t.status=c.status;
1638
+ if(c.stale_after)t.stale_after=c.stale_after;
1639
+ return Object.keys(t).length?t:null;};
1640
+ const metaFromRow=c=>{const m={description:(c&&c.description)||''};const t=rowTrust(c);if(t)m.trust=t;return m;};
1641
+ /* The one place meta reaches the DOM. Every producer-supplied string lands via
1642
+ textContent — never innerHTML — so no render path opens outside the
1643
+ sanitizer the bodies already pass through. */
1644
+ function renderMeta(el,meta){el.textContent='';
1645
+ const desc=String((meta&&meta.description)||'').trim();
1646
+ if(desc)el.appendChild(document.createTextNode(desc));
1647
+ else{const e=document.createElement('span');e.className='empty';e.textContent='no description';el.appendChild(e);}
1648
+ const t=meta&&meta.trust;if(!t)return;
1649
+ const line=document.createElement('span');line.className='meta-trust';
1650
+ const chip=(cls,text)=>{const s=document.createElement('span');s.className=cls;s.textContent=text;line.appendChild(s);};
1651
+ if(t.tier)chip('tier '+t.tier,t.tier);
1652
+ if(t.generated_at)chip('gen',t.generated_at+(t.generated_by?' by '+t.generated_by:''));
1653
+ if(foldStatus(t.status)!=='stable')chip('status '+statusSlug(t.status),t.status);
1654
+ if(isExpired(t.stale_after))chip('stale','expired '+t.stale_after);
1655
+ if(line.childNodes.length)el.appendChild(line);}
1555
1656
  function getNodeBody(id){return EMBED?Promise.resolve(EMBED.bodies[id]||''):fetch(NODE_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'');}
1556
- function getNodeMeta(id){return EMBED?Promise.resolve(metaFragment(EMBED_DESC[id])):fetch(META_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'');}
1657
+ function getNodeMeta(id){return EMBED?Promise.resolve(metaFromRow(EMBED_ROW[id])):fetch(META_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.json():{});}
1557
1658
  function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Untyped';const c=color[ty]||'#64748b';
1558
1659
  shownId=id;const opened=openPanel();sideBody.scrollTop=0;
1559
1660
  sideBody.innerHTML=`<button type="button" class="type facet" data-focus-type="${esc(ty)}" title="Show only ${esc(ty)} in the graph"><span class="dot" style="background:${c}"></span>${esc(ty)}</button>
@@ -1571,7 +1672,7 @@ function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Unty
1571
1672
  sideBody.querySelectorAll('[data-focus-tag]').forEach(b=>b.onclick=()=>{
1572
1673
  const t=b.getAttribute('data-focus-tag');tagFocused(t)?clearGraphFilter():focusGraphTag(t);});
1573
1674
  syncFacets();
1574
- getNodeMeta(id).then(h=>{const d=document.getElementById('desc');if(d){d.innerHTML=h||'<span class="empty">no description</span>';d.classList.remove('loading');}}).catch(()=>{});
1675
+ getNodeMeta(id).then(m=>{const d=document.getElementById('desc');if(d){renderMeta(d,m);d.classList.remove('loading');}}).catch(()=>{});
1575
1676
  const bodyEl=sideBody.querySelector('#body');
1576
1677
  getNodeBody(id).then(md=>{if(bodyEl.isConnected)renderMarkdown(bodyEl,md);}).catch(()=>{});
1577
1678
  return opened;}
@@ -1928,7 +2029,7 @@ function setTree(on){if(on===treeMode)return;if(on&&clustered)setClustered(false
1928
2029
  runLayout(layoutSel.value);}}
1929
2030
  btnTree.onclick=()=>setTree(!treeMode);
1930
2031
  /* ── the authored layer, drawn ──
1931
- The §6 map used to be visible only inside file-tree mode, where a folder node
2032
+ The §8 map used to be visible only inside file-tree mode, where a folder node
1932
2033
  stood in for a directory's index.md. It is a layer of its own now: switch it
1933
2034
  on under any layout and each `index.md` becomes a node, edged to the concepts
1934
2035
  it maps and to the child maps beneath it. Cluster and tree mode still work —
@@ -2209,8 +2310,8 @@ document.querySelectorAll('.rail-item').forEach(b=>b.onclick=()=>{
2209
2310
  function loadMiniSearch(){return _MiniSearch||(_MiniSearch=loadScript('https://cdn.jsdelivr.net/npm/minisearch@7.2.0/dist/umd/index.js').then(()=>window.MiniSearch));}
2210
2311
  function buildFtIndex(){return ftIndex?Promise.resolve(ftIndex):(ftBuilding||(ftBuilding=Promise.all([loadMiniSearch(),getCatalog()]).then(([MiniSearch,list])=>{
2211
2312
  const idx=new MiniSearch({idField:'id',fields:FT_FIELDS,storeFields:['id'],
2212
- extractField:(doc,f)=>f==='tags'?(doc.tags||[]).join(' '):f==='body'?((EMBED&&EMBED.bodies[doc.id])||''):(doc[f]==null?'':String(doc[f])),
2213
- searchOptions:{prefix:true,fuzzy:0.2,combineWith:'AND',boost:{title:5,id:4,tags:3,type:2,description:2,body:1}}});
2313
+ extractField:(doc,f)=>f==='tags'?(doc.tags||[]).join(' '):f==='body'?((EMBED&&EMBED.bodies[doc.id])||''):f==='sources'?((EMBED&&EMBED.sources[doc.id])||''):(doc[f]==null?'':String(doc[f])),
2314
+ searchOptions:{prefix:true,fuzzy:0.2,combineWith:'AND',boost:{title:5,id:4,tags:3,type:2,description:2,sources:1,body:1}}});
2214
2315
  idx.addAll(list);ftIndex=idx;
2215
2316
  if((view==='graph'||view==='catalog'||view==='files')&&(q[view]||'').trim())applySearch();
2216
2317
  return idx;}).catch(()=>{ftBuilding=null;return null;})));}
@@ -2242,11 +2343,30 @@ let CATALOG=null;
2242
2343
  otherwise) and fills descOf on the way, so even the pre-index substring
2243
2344
  fallback can match a graph node by its leaf description. */
2244
2345
  function getCatalog(){return CATALOG||(CATALOG=(EMBED?Promise.resolve(EMBED.catalog):fetch(CATALOG_ENDPOINT).then(r=>r.json()).then(d=>d.concepts)).then(list=>{list.forEach(c=>{descOf[c.id]=c.description||'';});return list;}));}
2245
- const catActiveTypes=new Set(), catActiveDirs=new Set(), catActiveTags=new Set();
2346
+ const catActiveTypes=new Set(), catActiveDirs=new Set(), catActiveTags=new Set(),
2347
+ catActiveStatus=new Set(), catActiveTrust=new Set();
2348
+ /* The two §5 facets, counted off the catalog — it is the only payload that
2349
+ knows them, so they cannot be built at boot like types and tags. Status
2350
+ counts the EFFECTIVE value (absent reads stable, the same rule --status
2351
+ narrows by); trust counts the derived tier. A facet with nothing to say
2352
+ stays hidden: the status group needs at least one DECLARED status, and the
2353
+ trust group at least one concept whose tier the page is willing to claim —
2354
+ otherwise every v0.1 bundle would wear a row of chips saying only
2355
+ "unverified". `showsTrust` is that willingness, and all three of the gate,
2356
+ the counts and the narrowing read it, so the facet describes exactly the
2357
+ cards wearing a tier chip. Counting rows the page shows no chip for made it
2358
+ read "unverified 3" over two chipped cards, and narrow to three. */
2359
+ let catStatusCounts={}, catTrustCounts={}, catShowStatus=false, catShowTrust=false;
2360
+ function catFacets(list){catStatusCounts={};catTrustCounts={};catShowTrust=false;
2361
+ list.forEach(c=>{const s=foldStatus(c.status);catStatusCounts[s]=(catStatusCounts[s]||0)+1;
2362
+ if(showsTrust(c)){catTrustCounts[c.trust]=(catTrustCounts[c.trust]||0)+1;catShowTrust=true;}});
2363
+ catShowStatus=list.some(c=>c.status);}
2364
+ const facetByCount=counts=>Object.keys(counts).sort((a,b)=>counts[b]-counts[a]||a.localeCompare(b));
2365
+ const facetItems=counts=>facetByCount(counts).map(v=>({v:v,n:counts[v]}));
2246
2366
  const catByCount=types.slice().sort((a,b)=>(TYPES[b]||[]).length-(TYPES[a]||[]).length);
2247
2367
  const catChip=t=>`<span class="chip" data-t="${esc(t)}"><span class="dot" style="background:${color[t]}"></span>${esc(t)} <span class="c">${(TYPES[t]||[]).length}</span></span>`;
2248
2368
  function syncCatChips(){document.querySelectorAll('#cat-types .chip').forEach(c=>c.classList.toggle('on',catActiveTypes.has(c.getAttribute('data-t'))));
2249
- const n=catActiveTypes.size+catActiveDirs.size+catActiveTags.size;
2369
+ const n=catActiveTypes.size+catActiveDirs.size+catActiveTags.size+catActiveStatus.size+catActiveTrust.size;
2250
2370
  const b=document.getElementById('cat-filters-btn');b.classList.toggle('on-filter',n>0);b.querySelector('.fbadge').textContent=n;}
2251
2371
  function catToggle(t){toggleSet(catActiveTypes,t);renderCatFilters();renderCatalog();}
2252
2372
  function wireCatChips(el){el.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>catToggle(ch.getAttribute('data-t')));}
@@ -2258,6 +2378,11 @@ function renderCatFilters(){const qq=(document.getElementById('cat-filter-search
2258
2378
  chipRow(document.getElementById('cat-ftypes'),typeItems(catByCount.filter(match)),'data-t',t=>catActiveTypes.has(t),t=>{toggleSet(catActiveTypes,t);after();});
2259
2379
  chipRow(document.getElementById('cat-fdirs'),dirItems(graphDirs.filter(dirMatch(qq))),'data-dir',d=>catActiveDirs.has(d),d=>{toggleSet(catActiveDirs,d);after();});
2260
2380
  chipRow(document.getElementById('cat-ftags'),tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',t=>catActiveTags.has(t),t=>{toggleSet(catActiveTags,t);after();});
2381
+ const sItems=facetItems(catStatusCounts).filter(o=>match(o.v)), tItems=facetItems(catTrustCounts).filter(o=>match(o.v));
2382
+ document.getElementById('cat-fstatus-group').hidden=!(catShowStatus&&sItems.length);
2383
+ document.getElementById('cat-ftrust-group').hidden=!(catShowTrust&&tItems.length);
2384
+ chipRow(document.getElementById('cat-fstatus'),sItems,'data-status',s=>catActiveStatus.has(s),s=>{toggleSet(catActiveStatus,s);after();});
2385
+ chipRow(document.getElementById('cat-ftrust'),tItems,'data-trust',t=>catActiveTrust.has(t),t=>{toggleSet(catActiveTrust,t);after();});
2261
2386
  syncCatChips();}
2262
2387
  function initCatalog(){inited.catalog=true;
2263
2388
  // Only the five most common types get inline chips; everything else — types,
@@ -2268,13 +2393,18 @@ function initCatalog(){inited.catalog=true;
2268
2393
  const cf=document.getElementById('cat-filters');
2269
2394
  document.getElementById('cat-filters-btn').onclick=()=>cf.classList.toggle('open');
2270
2395
  document.getElementById('cat-filters-close').onclick=()=>cf.classList.remove('open');
2271
- document.getElementById('cat-filters-reset').onclick=()=>{catActiveTypes.clear();catActiveDirs.clear();catActiveTags.clear();renderCatFilters();renderCatalog();};
2396
+ document.getElementById('cat-filters-reset').onclick=()=>{catActiveTypes.clear();catActiveDirs.clear();catActiveTags.clear();
2397
+ catActiveStatus.clear();catActiveTrust.clear();renderCatFilters();renderCatalog();};
2272
2398
  document.getElementById('cat-filter-search').oninput=renderCatFilters;
2273
- getCatalog().then(renderCatalog);}
2399
+ // The §5 facets only exist once the catalog is in, so the panel renders
2400
+ // twice: the chips boot knows immediately, then the two that had to wait.
2401
+ getCatalog().then(list=>{catFacets(list);renderCatFilters();renderCatalog();});}
2274
2402
  function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.catalog||'').toLowerCase();const ids=ftMatch(q.catalog);
2275
2403
  const rows=list.filter(c=>{if(catActiveTypes.size&&!catActiveTypes.has(c.type))return false;
2276
2404
  if(catActiveDirs.size&&![...catActiveDirs].some(d=>underDir(dirOf(c.id),d)))return false;
2277
2405
  if(catActiveTags.size&&!c.tags.some(t=>catActiveTags.has(t)))return false;
2406
+ if(catActiveStatus.size&&!catActiveStatus.has(foldStatus(c.status)))return false;
2407
+ if(catActiveTrust.size&&!(showsTrust(c)&&catActiveTrust.has(c.trust)))return false;
2278
2408
  if(ids)return ids.has(c.id);
2279
2409
  if(s){const hay=(c.title+' '+c.description+' '+c.type+' '+c.tags.join(' ')+' '+c.id).toLowerCase();if(!hay.includes(s))return false;}return true;});
2280
2410
  document.getElementById('cat-cnt').textContent=rows.length+' of '+list.length+' concepts';
@@ -2284,14 +2414,16 @@ function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.
2284
2414
  const g=document.getElementById('cat-grid');
2285
2415
  if(!rows.length){g.innerHTML='<div class="none">No concepts match — try clearing the filters.</div>';return;}
2286
2416
  g.innerHTML=rows.map(c=>{const cc=color[c.type]||'#64748b';
2287
- const st=c.status?`<span class="status ${c.status==='shipped'?'shipped':''}">${esc(c.status)}</span>`:'';
2417
+ const st=statusChip(c);
2418
+ const tr=trustChip(c);
2288
2419
  const tags=c.tags.slice(0,4).map(t=>`<span class="tag">${esc(t)}</span>`).join('');
2289
- const ts=c.timestamp?`<span class="mini">⌂ ${esc(c.timestamp)}</span>`:'';
2420
+ const ts=genChip(c);
2421
+ const sa=isExpired(c.stale_after)?`<span class="mini stale">⚠ expired ${esc(c.stale_after)}</span>`:'';
2290
2422
  const lk=(c.links_out+c.links_in)?`<span class="mini links">↳ ${c.links_out+c.links_in} links</span>`:'';
2291
2423
  return `<article class="card" tabindex="0" data-id="${esc(c.id)}">
2292
- <div class="r1"><span class="badge"><span class="dot" style="background:${cc}"></span>${esc(c.type)}</span>${st}<span class="area">${esc(c.dir)}/</span></div>
2424
+ <div class="r1"><span class="badge"><span class="dot" style="background:${cc}"></span>${esc(c.type)}</span>${st}${tr}<span class="area">${esc(c.dir)}/</span></div>
2293
2425
  <h3>${esc(c.title)}</h3><p class="cd">${esc(c.description)||'<span class="empty">No description.</span>'}</p>
2294
- <div class="cm">${tags}${ts}${lk}</div></article>`;}).join('');
2426
+ <div class="cm">${tags}${ts}${sa}${lk}</div></article>`;}).join('');
2295
2427
  g.querySelectorAll('.card').forEach(el=>{const id=el.dataset.id;el.onclick=()=>goToGraph(id);
2296
2428
  el.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();goToGraph(id);}};});});}
2297
2429
 
@@ -2502,11 +2634,11 @@ function openReserved(kind,path){fileSel=path;
2502
2634
  const body=document.getElementById('fp-body');body.innerHTML='<p class="loading">loading…</p>';
2503
2635
  const done=txt=>{const wrap=document.createElement('div');wrap.className='body';renderMarkdown(wrap,txt);body.innerHTML='';body.appendChild(wrap);};
2504
2636
  if(kind==='index')getIndex().then(dirs=>{const d=dirs.find(x=>x.dir===dirOfPath(path));
2505
- type.textContent=d&&d.synthesized?'directory index · synthesized':'directory index · §6';
2637
+ type.textContent=d&&d.synthesized?'directory index · synthesized':'directory index · §8';
2506
2638
  if(d&&d.synthesized){body.innerHTML='<div class="body">'+listingHtml(d.listing)+'</div>';
2507
2639
  body.querySelectorAll('[data-go]').forEach(x=>x.onclick=()=>openFile(x.getAttribute('data-go')));}
2508
2640
  else done((d&&d.body)||'');});
2509
- else{type.textContent='update log · §7';
2641
+ else{type.textContent='update log · §9';
2510
2642
  LOGS=null;getLogs().then(logs=>{const l=logs.find(x=>x.path===path);done((l&&l.content)||'');});}}
2511
2643
 
2512
2644
  /* ── tags ── */
@@ -2584,13 +2716,13 @@ const fsOn=()=>document.fullscreenElement===appEl;
2584
2716
  btnFull.onclick=()=>{if(fsOn())document.exitFullscreen();else if(appEl.requestFullscreen)appEl.requestFullscreen();};
2585
2717
  document.addEventListener('fullscreenchange',()=>btnFull.setAttribute('aria-pressed',String(fsOn())));
2586
2718
 
2587
- /* ── the §6 map payload — feeds folder clicks, the Indexes tab, and previews ── */
2719
+ /* ── the §8 map payload — feeds folder clicks, the Indexes tab, and previews ── */
2588
2720
  let INDEX=null;
2589
2721
  function getIndex(){return INDEX||(INDEX=EMBED?Promise.resolve(EMBED.index):fetch(INDEX_ENDPOINT).then(r=>r.json()).then(d=>d.directories));}
2590
2722
  function listingHtml(items){if(!items||!items.length)return '<p class="empty" style="margin:10px 0 0">No concepts directly here.</p>';
2591
2723
  return '<ul class="ix-listing">'+items.map(it=>`<li><a data-go="${esc(it.id)}">${esc(it.title||it.id)}</a>${it.description?` <span class="d">— ${esc(it.description)}</span>`:''}</li>`).join('')+'</ul>';}
2592
2724
 
2593
- /* ── log payload (the §7 history) — read by the Files view's log entries ── */
2725
+ /* ── log payload (the §9 history) — read by the Files view's log entries ── */
2594
2726
  let LOGS=null;
2595
2727
  function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LOG_ENDPOINT).then(r=>r.json()).then(d=>d.logs));}
2596
2728
 
@@ -3215,8 +3347,8 @@ function bridgeReport(v,shown,total){if(bridge)bridge.report(v,shown,total);}
3215
3347
  '<p class="pv-meta">'+out+' link'+(out===1?'':'s')+' out · '+inn+' in</p>';
3216
3348
  bodyIn.innerHTML=relList('Links to',outL[id]||[])+relList('Linked from',inL[id]||[])+'<div class="body" id="pv-md"></div>';
3217
3349
  bodyIn.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
3218
- getNodeMeta(id).then(h=>{const d=document.getElementById('pv-desc');
3219
- if(d&&shown===id){d.innerHTML=h||'<span class="empty">no description</span>';d.classList.remove('loading');}}).catch(()=>{});
3350
+ getNodeMeta(id).then(m=>{const d=document.getElementById('pv-desc');
3351
+ if(d&&shown===id){renderMeta(d,m);d.classList.remove('loading');}}).catch(()=>{});
3220
3352
  if(snap!=='peek')loadBody();
3221
3353
  return true;}
3222
3354
  /* lazy: the body is markdown nobody can see at peek, and on a phone peek is the
@@ -3245,7 +3377,7 @@ function bridgeReport(v,shown,total){if(bridge)bridge.report(v,shown,total);}
3245
3377
  bodyIn.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
3246
3378
  raise();}).catch(function(){});}
3247
3379
  function fillLog(path){LOGS=null;getLogs().then(function(logs){const l=logs.find(x=>x.path===path);if(!l)return;
3248
- renderMarkdown(fillHead('update log · §7',path,'the §7 history'),l.content||'');
3380
+ renderMarkdown(fillHead('update log · §9',path,'the §9 history'),l.content||'');
3249
3381
  raise();}).catch(function(){});}
3250
3382
 
3251
3383
  /* ---- the camera -------------------------------------------------------- */
@@ -55,14 +55,22 @@ module OKF
55
55
  # answer. Every key here is data a client getter reads from EMBED instead of
56
56
  # fetching — and each derives from the *same* folder method the matching
57
57
  # OKF::Server::App endpoint uses, so the bake and the live server cannot
58
- # drift (/node/meta is the exception: the fragment is derived on the client
59
- # from the catalog's raw description, so no map is baked for it).
58
+ # drift (/node/meta is the exception: the trust line is composed on the
59
+ # client from the catalog row, so no map is baked for it). `bodies` and
60
+ # `sources` have no bare endpoint at all — they exist so a static file
61
+ # searches offline what the server-mode index deliberately leaves out; the
62
+ # sources text is the same join the Ruby engines index, so `--engine index`
63
+ # and the baked page rank identically. Empty strings are baked too: an
64
+ # undefined getter throws client-side.
60
65
  def self.payload(folder)
61
66
  {
62
67
  catalog: folder.catalog,
63
68
  index: folder.directory_index,
64
69
  logs: folder.log_entries,
65
- bodies: folder.concepts.each_with_object({}) { |concept, map| map[concept.id] = concept.body.to_s }
70
+ bodies: folder.concepts.each_with_object({}) { |concept, map| map[concept.id] = concept.body.to_s },
71
+ sources: folder.concepts.each_with_object({}) do |concept, map|
72
+ map[concept.id] = OKF::Bundle::Search.field_texts(concept)["sources"]
73
+ end
66
74
  }
67
75
  end
68
76
 
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "path"
4
+
5
+ module OKF
6
+ # Shell-side containment for reads. `Path.under?` is the pure decision; the
7
+ # `File.realpath` that feeds it is disk I/O, so it lives here, out of the pure
8
+ # core. Every byte a bundle serves is read through this one primitive — the
9
+ # Reader's bulk load, `Concept::File`, the live `log.md` re-read, the MCP
10
+ # shell's concept and index reads — so a symlink whose name sits inside the
11
+ # root but whose target does not is refused in exactly one place. A read that
12
+ # rolled its own check could quietly drift and reopen the escape; there is
13
+ # nothing to drift from here.
14
+ #
15
+ # Scope. This closes the escape a *symlink* opens — the one a bundle can carry
16
+ # through a git clone, a copy or a tarball, which is the portable, adversarial
17
+ # case (a shared bundle whose author points a link at your secrets). It does
18
+ # not close a *hardlink*: File.realpath cannot resolve one (a hardlink shares
19
+ # its target's inode and keeps its own in-root path), and the obvious guard —
20
+ # rejecting st_nlink > 1 — would break a bundle on a deduplicating filesystem
21
+ # (a Nix store, some CI caches) where ordinary files legitimately share links.
22
+ # A hardlink to an outside file requires local write access to the served
23
+ # directory on the target's own filesystem, and cannot survive being copied,
24
+ # so it is a narrower, non-portable threat left deliberately out of scope.
25
+ module SafeRead
26
+ module_function
27
+
28
+ # The file's real, symlink-resolved location, or Path::Error if it escapes
29
+ # +root+. Pass +real_root+ when resolving many paths under one root (the
30
+ # Reader's loop) so the root is resolved once, not per file.
31
+ def contained_path!(root, path, real_root: nil)
32
+ real = ::File.realpath(path)
33
+ real_root ||= ::File.realpath(root)
34
+ raise Path::Error, "symlink target escapes bundle root" unless Path.under?(real_root, real)
35
+
36
+ real
37
+ end
38
+
39
+ # +path+'s bytes, read from its *resolved* location — so a symlink swapped in
40
+ # anywhere but the final component is caught, since the resolved path has no
41
+ # links left to follow — and refused if it escapes. The microscopic window
42
+ # between resolving and opening the leaf is not closed here (that needs an
43
+ # open-by-descriptor the 2.4 stdlib does not lend itself to); reading the
44
+ # resolved path is strictly better than reading the caller's raw name, which
45
+ # re-followed every link on every read.
46
+ def read!(root, path, real_root: nil, encoding: "UTF-8")
47
+ ::File.read(contained_path!(root, path, real_root: real_root), encoding: encoding)
48
+ end
49
+ end
50
+ end