@docsworth/cli 0.2.0 → 0.3.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 (49) hide show
  1. package/README.md +60 -8
  2. package/dist/cli.js +52 -3
  3. package/dist/core/acl.d.ts +9 -0
  4. package/dist/core/acl.js +40 -6
  5. package/dist/core/adopt.d.ts +68 -0
  6. package/dist/core/adopt.js +665 -0
  7. package/dist/core/config.d.ts +6 -1
  8. package/dist/core/config.js +46 -2
  9. package/dist/core/edits.d.ts +13 -0
  10. package/dist/core/edits.js +20 -0
  11. package/dist/core/frontmatter.d.ts +12 -0
  12. package/dist/core/frontmatter.js +16 -7
  13. package/dist/core/gfm.d.ts +13 -0
  14. package/dist/core/gfm.js +244 -0
  15. package/dist/core/links.d.ts +82 -0
  16. package/dist/core/links.js +345 -0
  17. package/dist/core/lint.d.ts +1 -2
  18. package/dist/core/lint.js +40 -6
  19. package/dist/core/mcp/server.js +11 -2
  20. package/dist/core/printable.d.ts +4 -0
  21. package/dist/core/printable.js +16 -0
  22. package/dist/core/references.d.ts +38 -3
  23. package/dist/core/references.js +543 -41
  24. package/dist/core/repo.d.ts +4 -0
  25. package/dist/core/repo.js +3 -1
  26. package/dist/core/schema.d.ts +4 -0
  27. package/dist/core/schema.js +8 -6
  28. package/dist/core/search.d.ts +8 -0
  29. package/dist/core/search.js +34 -0
  30. package/dist/core/serve/server.d.ts +1 -2
  31. package/dist/core/serve/sessions.d.ts +5 -4
  32. package/dist/core/serve/sessions.js +102 -29
  33. package/dist/core/service.d.ts +118 -20
  34. package/dist/core/service.js +249 -91
  35. package/dist/core/stale.js +2 -1
  36. package/dist/core/tool-registry.js +6 -3
  37. package/dist/core/verify-pages.d.ts +19 -0
  38. package/dist/core/verify-pages.js +110 -0
  39. package/dist/core/version.d.ts +2 -2
  40. package/dist/core/version.js +2 -2
  41. package/dist/web/assets/index-8J-FUbb0.js +29 -0
  42. package/dist/web/assets/index-BGpAZzeM.css +1 -0
  43. package/dist/web/index.html +2 -2
  44. package/docs/format-spec.md +40 -2
  45. package/package.json +2 -2
  46. package/plugin/.claude-plugin/plugin.json +1 -1
  47. package/plugin/bin/docsworth-mcp.mjs +1 -1
  48. package/dist/web/assets/index-BglLMIyN.css +0 -1
  49. package/dist/web/assets/index-DugFPoRu.js +0 -25
@@ -14,10 +14,11 @@ import { MIRRORED_NOTE, NOT_CITABLE_NOTE } from "./mcp/text.js";
14
14
  import { buildNewPage, NEW_PAGE_PREFIX, slugify } from "./new-page.js";
15
15
  import { toolRefusal } from "./policy.js";
16
16
  import { WRITE_TOOLS } from "./tool-registry.js";
17
+ import { EXTERNAL, linkPath, pageLinks } from "./links.js";
17
18
  import { extractLinks, loadRepo, resolveLink } from "./repo.js";
18
19
  import { ACL_INTERNAL, PAGE_TYPES } from "./schema.js";
19
20
  import { splitFrontmatter } from "./frontmatter.js";
20
- import { SearchIndex, snippet } from "./search.js";
21
+ import { plainText, SearchIndex, snippet } from "./search.js";
21
22
  import { listStale } from "./stale.js";
22
23
  import { applyEdits, assertProtectedFieldsUnchanged, openPagePr, replaceBody, setLastVerified, WriteError } from "./writes.js";
23
24
  const count = (text, needle) => text.split(needle).length - 1;
@@ -47,60 +48,76 @@ export const MIRROR_LIMIT = 3;
47
48
  const LOCAL_IDENTITY_FOR_POLICY = { mode: "local", login: null, teams: new Set(), repoAccess: true, canWrite: false };
48
49
  // Where a mirrored page is read from when its links are resolved: outside the knowledge root.
49
50
  const MIRROR_ORIGIN = ".knowledge/mirror";
50
- export class KnowledgeService {
51
+ /**
52
+ * Loads the repo and keeps it until something changes, for one service or, in `docsworth
53
+ * serve`, for every session's service: one copy of the pages and their index however many
54
+ * tabs are open (a 3,000-page repo took about 50 MB per copy). It owns the one file watcher.
55
+ * What belongs to a session (its taint, its cached default branch) stays on the service.
56
+ */
57
+ export class RepoLoader {
51
58
  root;
52
- identity;
53
- now;
54
- repo;
55
- index;
59
+ loaded = null;
56
60
  dirty = false;
57
- /** mtimes of the non-page inputs (config, CODEOWNERS) the loaded repo was built from. */
58
- inputs = "";
61
+ /** watch() was asked for, and close() hasn't stopped it. */
62
+ watching = false;
59
63
  watcher = null;
60
- watchedDir = null;
61
64
  settle = null;
62
- defaultBranch = null;
63
- /** Mirrored pages, when mirror.serve is on; one search index per audience (views). */
64
- mirror = [];
65
- mirrorViews = new Map();
66
- /** Team ACLs whose content this session has seen (write-path taint check). */
67
- tainted = new Set();
68
- /** The random end of a drafted page's id and file name (replaced in tests). */
69
- draftSuffix = () => randomBytes(6).toString("hex");
70
- constructor(root, identity, now = () => new Date()) {
65
+ /** The directory being watched, or to be once it exists (tests read it). */
66
+ watchedDir = null;
67
+ /** How many times the repo was loaded (tests). */
68
+ loads = 0;
69
+ /** Called after each load: serve moves its idle sessions onto it, so no old load is kept. */
70
+ onLoad = null;
71
+ constructor(root) {
71
72
  this.root = root;
72
- this.identity = identity;
73
- this.now = now;
74
- this.reload();
75
73
  }
76
- reload() {
77
- this.repo = loadRepo(this.root);
78
- this.snap = null; // recomputed on first use, so it always names what is being served
74
+ /** The current load: the same object until a change is seen, then a new one, built once. */
75
+ current() {
76
+ if (!this.loaded || this.dirty || this.inputsStamp(this.loaded.repo) !== this.loaded.inputs)
77
+ this.load();
78
+ // A knowledge root that couldn't be watched (the config named one that didn't exist
79
+ // yet): try again on every call, and load it as soon as it can be watched.
80
+ if (this.watching && !this.watcher) {
81
+ this.follow(this.loaded.repo);
82
+ if (this.watcher)
83
+ this.load();
84
+ }
85
+ return this.loaded;
86
+ }
87
+ /** The newest load, without looking for changes. */
88
+ latest() {
89
+ return this.loaded;
90
+ }
91
+ /** Has a change been seen that the next call will load (tests). */
92
+ get pending() {
93
+ return this.dirty;
94
+ }
95
+ load() {
96
+ const repo = loadRepo(this.root);
79
97
  // Index bodies with links to non-internal pages already redacted, so a team page's
80
98
  // title in a public page's link text can't surface through ranking or snippets.
81
99
  const publicView = { mode: "local", login: null, teams: new Set(), repoAccess: true, canWrite: false };
82
- this.mirror = this.repo.config.mirror.serve ? servedMirror(this.root, this.repo.config.mirror.out_dir) : [];
100
+ const mirror = repo.config.mirror.serve ? servedMirror(this.root, repo.config.mirror.out_dir) : [];
83
101
  // Links to mirrored pages outside the public view are redacted from the index too.
84
- const teamMirror = this.mirror.filter((m) => !canSee(m, publicView));
85
- this.index = new SearchIndex(this.repo.pages.map((p) => ({
102
+ const teamMirror = mirror.filter((m) => !canSee(m, publicView));
103
+ const index = new SearchIndex(repo.pages.map((p) => ({
86
104
  title: p.title,
87
105
  headings: p.headings,
88
106
  description: p.description,
89
- body: redactHiddenLinks(redactLinks(p.body, p.path, this.repo, publicView), teamMirror, RESTRICTED),
107
+ body: redactHiddenLinks(redactLinks(p.body, p.path, repo, publicView), teamMirror, RESTRICTED),
90
108
  keywords: [p.id, ...(p.applies_to ?? [])],
91
109
  })));
92
- this.mirrorViews.clear();
110
+ // The snapshot is recomputed on first use, so it always names what is being served.
111
+ this.loaded = { repo, index, mirror, mirrorViews: new Map(), inputs: this.inputsStamp(repo), snap: null };
93
112
  this.dirty = false;
94
- this.inputs = this.inputsStamp();
95
- // Config may have changed: forget the cached branch, follow a moved knowledge root.
96
- this.defaultBranch = null;
97
- if (this.watcher && this.watchedDir !== join(this.root, this.repo.config.knowledge_root)) {
98
- this.close();
99
- this.watch();
100
- }
113
+ this.loads++;
114
+ this.onLoad?.();
115
+ // Config may have moved the knowledge root: follow it.
116
+ if (this.watching && this.watchedDir !== join(this.root, repo.config.knowledge_root))
117
+ this.follow(repo);
101
118
  }
102
119
  /** Config and CODEOWNERS live outside the watched knowledge root; stat them per call. */
103
- inputsStamp() {
120
+ inputsStamp(repo) {
104
121
  const stamp = (abs) => {
105
122
  try {
106
123
  return statSync(abs).mtimeMs;
@@ -110,9 +127,110 @@ export class KnowledgeService {
110
127
  }
111
128
  };
112
129
  // A mirror run rewrites its ownership records, so their directory's mtime changes.
113
- const mirror = this.repo?.config.mirror.serve ? [stamp(join(stateDir(this.root), "mirror"))] : [];
130
+ const mirror = repo.config.mirror.serve ? [stamp(join(stateDir(this.root), "mirror"))] : [];
114
131
  return [...[CONFIG_PATH, ...CODEOWNERS_LOCATIONS].map((rel) => stamp(join(this.root, rel))), ...mirror].join("|");
115
132
  }
133
+ /** Load again on the next call. */
134
+ invalidate() {
135
+ this.dirty = true;
136
+ }
137
+ /** Mark the load stale on any change under the knowledge root. */
138
+ watch() {
139
+ if (this.watching)
140
+ return;
141
+ const repo = this.current().repo;
142
+ this.watching = true;
143
+ this.follow(repo);
144
+ }
145
+ /** Watch `repo`'s knowledge root, in place of whatever was watched before. */
146
+ follow(repo) {
147
+ this.stop();
148
+ this.watchedDir = join(this.root, repo.config.knowledge_root);
149
+ try {
150
+ this.watcher = watch(this.watchedDir, { recursive: true }, () => {
151
+ this.dirty = true;
152
+ });
153
+ }
154
+ catch {
155
+ return; // not there yet: current() tries again on every call
156
+ }
157
+ // macOS drops events for changes made while a new watch stream is still starting
158
+ // (3 of 20 writes made right after a restart were never reported). Reload once the
159
+ // stream has had time to start, so a page changed in that window is not served stale.
160
+ this.settle = setTimeout(() => (this.dirty = true), WATCH_SETTLE_MS);
161
+ this.settle.unref();
162
+ }
163
+ close() {
164
+ this.watching = false;
165
+ this.stop();
166
+ }
167
+ stop() {
168
+ this.watcher?.close();
169
+ this.watcher = null;
170
+ if (this.settle)
171
+ clearTimeout(this.settle);
172
+ this.settle = null;
173
+ }
174
+ }
175
+ export class KnowledgeService {
176
+ root;
177
+ identity;
178
+ now;
179
+ loader;
180
+ /** A loader this service made is its own to close; a shared one belongs to its owner. */
181
+ ownsLoader;
182
+ loaded;
183
+ repo;
184
+ index;
185
+ defaultBranch = null;
186
+ /** Mirrored pages, when mirror.serve is on; one search index per audience (views). */
187
+ mirror = [];
188
+ mirrorViews;
189
+ /** Team ACLs whose content this session has seen (write-path taint check). */
190
+ tainted = new Set();
191
+ /** The random end of a drafted page's id and file name (replaced in tests). */
192
+ draftSuffix = () => randomBytes(6).toString("hex");
193
+ /** `loader`: a load shared with other services (`docsworth serve`'s sessions); else its own. */
194
+ constructor(root, identity, now = () => new Date(), loader) {
195
+ this.root = root;
196
+ this.identity = identity;
197
+ this.now = now;
198
+ this.ownsLoader = !loader;
199
+ this.loader = loader ?? new RepoLoader(root);
200
+ this.adopt(this.loader.current());
201
+ }
202
+ /** Load the repo again now. */
203
+ reload() {
204
+ this.loader.invalidate();
205
+ this.current();
206
+ }
207
+ /**
208
+ * Point at the loader's newest load, letting go of an older one. Only while no call of
209
+ * this service is running: `docsworth serve` calls it for a session with no request in
210
+ * flight, so an idle tab doesn't keep a whole old copy of the repo alive.
211
+ */
212
+ release() {
213
+ const latest = this.loader.latest();
214
+ if (latest && latest !== this.loaded)
215
+ this.adopt(latest);
216
+ }
217
+ /** Take a new load, and drop what this service cached from the last one. */
218
+ adopt(loaded) {
219
+ this.loaded = loaded;
220
+ this.repo = loaded.repo;
221
+ this.index = loaded.index;
222
+ this.mirror = loaded.mirror;
223
+ this.mirrorViews = loaded.mirrorViews;
224
+ // Config may have changed: forget the cached branch.
225
+ this.defaultBranch = null;
226
+ }
227
+ /** The watched directory and a pending reload live on the loader (tests read them here). */
228
+ get watchedDir() {
229
+ return this.loader.watchedDir;
230
+ }
231
+ get dirty() {
232
+ return this.loader.pending;
233
+ }
116
234
  /**
117
235
  * The mirrored pages `who` may see, each with links to the ones they can't see
118
236
  * redacted (and to repo pages they can't see: a wiki page can link to one by its URL),
@@ -133,37 +251,25 @@ export class KnowledgeService {
133
251
  }
134
252
  return view;
135
253
  }
136
- /** Reload on the next call. `docsworth serve` runs one watcher for all its sessions' services. */
254
+ /** Reload on the next call. */
137
255
  invalidate() {
138
- this.dirty = true;
256
+ this.loader.invalidate();
139
257
  }
140
258
  /** Mark the index dirty on any change under the knowledge root. */
141
259
  watch() {
142
- if (this.watcher)
143
- return;
144
- this.watchedDir = join(this.root, this.repo.config.knowledge_root);
145
- this.watcher = watch(this.watchedDir, { recursive: true }, () => {
146
- this.dirty = true;
147
- });
148
- // macOS drops events for changes made while a new watch stream is still starting
149
- // (3 of 20 writes made right after a restart were never reported). Reload once the
150
- // stream has had time to start, so a page changed in that window is not served stale.
151
- this.settle = setTimeout(() => (this.dirty = true), WATCH_SETTLE_MS);
152
- this.settle.unref();
260
+ this.loader.watch();
153
261
  }
262
+ /** Stops this service's own watcher; a shared loader's is its owner's to stop. */
154
263
  close() {
155
- this.watcher?.close();
156
- this.watcher = null;
157
- if (this.settle)
158
- clearTimeout(this.settle);
159
- this.settle = null;
264
+ if (this.ownsLoader)
265
+ this.loader.close();
160
266
  }
161
267
  current() {
162
- if (this.dirty || this.inputsStamp() !== this.inputs)
163
- this.reload();
268
+ const loaded = this.loader.current();
269
+ if (loaded !== this.loaded)
270
+ this.adopt(loaded);
164
271
  return this.repo;
165
272
  }
166
- snap = null;
167
273
  /**
168
274
  * The knowledge actually being served: the checkout's HEAD and whether the served files
169
275
  * differ from it, plus a hash of the loaded config, ownership and pages. Refreshed after
@@ -171,23 +277,24 @@ export class KnowledgeService {
171
277
  */
172
278
  snapshot() {
173
279
  const repo = this.current();
174
- if (this.snap)
175
- return this.snap;
280
+ const loaded = this.loaded;
281
+ if (loaded.snap)
282
+ return loaded.snap;
176
283
  const h = createHash("sha256");
177
284
  h.update(JSON.stringify(repo.config));
178
285
  h.update(JSON.stringify(repo.codeowners.rules.map((r) => [r.pattern, r.owners.map((o) => o.raw)])));
179
286
  for (const p of [...repo.pages].sort((a, b) => a.path.localeCompare(b.path)))
180
287
  h.update(`${p.path}\0${JSON.stringify(p)}\0`);
181
- for (const m of this.mirror)
288
+ for (const m of loaded.mirror)
182
289
  h.update(`${m.id}\0${JSON.stringify(m)}\0`);
183
290
  const head = spawnSync("git", ["-C", this.root, "rev-parse", "HEAD"], { encoding: "utf8" });
184
- const status = head.status === 0 ? spawnSync("git", ["-C", this.root, "status", "--porcelain", "--", repo.config.knowledge_root, CONFIG_PATH, ...CODEOWNERS_LOCATIONS], { encoding: "utf8" }) : null;
185
- this.snap = {
291
+ const status = head.status === 0 ? spawnSync("git", ["-C", this.root, "--literal-pathspecs", "status", "--porcelain", "--", repo.config.knowledge_root, CONFIG_PATH, ...CODEOWNERS_LOCATIONS], { encoding: "utf8" }) : null;
292
+ loaded.snap = {
186
293
  commit: head.status === 0 ? head.stdout.trim() : null,
187
294
  dirty: status && status.status === 0 ? status.stdout.trim().length > 0 : null,
188
295
  content_sha256: h.digest("hex"),
189
296
  };
190
- return this.snap;
297
+ return loaded.snap;
191
298
  }
192
299
  /** Every page id, team and applies_to tag the repo uses: what an audited argument may record. */
193
300
  knownValues() {
@@ -265,6 +372,11 @@ export class KnowledgeService {
265
372
  async search(args) {
266
373
  if (!args.query?.trim())
267
374
  throw new ToolError("invalid", "query is empty");
375
+ const asText = args.snippets === "text";
376
+ // Cut from the redacted page either way: text only takes markup away. Each is at most 240
377
+ // characters around the query; a text window isn't the markdown one's, which may end
378
+ // inside markup (docs/security-model.md, "Redacted links").
379
+ const cut = (markdown) => snippet(asText ? plainText(markdown) : markdown, args.query);
268
380
  const who = await this.identity.get();
269
381
  this.permit("search", who);
270
382
  const repo = this.current();
@@ -293,7 +405,7 @@ export class KnowledgeService {
293
405
  continue;
294
406
  results.push({
295
407
  ...this.summary(page),
296
- snippet: snippet(this.pageText(page, who), args.query),
408
+ snippet: cut(this.pageText(page, who)),
297
409
  score: hit.score,
298
410
  ...(status.citable ? {} : { gate: status.gate_reason }),
299
411
  });
@@ -309,18 +421,18 @@ export class KnowledgeService {
309
421
  // Served from memory, so checked against its file first: one edited or deleted
310
422
  // since loading is dropped, and the mirror reloaded on the next call.
311
423
  if (!stillOwned(view.origin[hit.index])) {
312
- this.dirty = true;
424
+ this.invalidate();
313
425
  continue;
314
426
  }
315
427
  const m = view.pages[hit.index];
316
- mirrored.push({ id: m.id, title: m.title, source: m.source, source_url: m.url, owner_team: m.owner_team, last_edited: m.last_edited, citable: false, snippet: snippet(m.markdown, args.query) });
428
+ mirrored.push({ id: m.id, title: m.title, source: m.source, source_url: m.url, owner_team: m.owner_team, last_edited: m.last_edited, citable: false, snippet: cut(m.markdown) });
317
429
  }
318
430
  this.taint(mirrored.map((h) => view.pages.find((m) => m.id === h.id)));
319
431
  }
320
432
  this.noteRead("search", results.map((r) => repo.byId.get(r.id)));
321
433
  this.taint(excluded.map((e) => repo.byId.get(e.id)));
322
434
  const notes = [...(excluded.length ? [NOT_CITABLE_NOTE] : []), ...(mirrored.length ? [MIRRORED_NOTE] : [])];
323
- return { results, excluded, ...(mirrored.length ? { mirrored } : {}), ...(notes.length ? { note: notes.join(" ") } : {}) };
435
+ return { results, excluded, ...(mirrored.length ? { mirrored } : {}), ...(notes.length ? { note: notes.join(" ") } : {}), ...(asText ? { snippets: "text" } : {}) };
324
436
  }
325
437
  async read(args) {
326
438
  return this.readAs("read", args, true);
@@ -385,7 +497,7 @@ export class KnowledgeService {
385
497
  // Changed since loading: don't serve it, and reload on the next call.
386
498
  const changed = Boolean(m) && !stillOwned(view.origin[at]);
387
499
  if (changed)
388
- this.dirty = true;
500
+ this.invalidate();
389
501
  if (!m || changed)
390
502
  throw new ToolError("not_found", `no page with id \`${id}\``);
391
503
  this.taint([m]);
@@ -488,28 +600,47 @@ export class KnowledgeService {
488
600
  pro: opts.pro ?? [],
489
601
  };
490
602
  }
491
- /** Counts over the caller's visible pages, by team, with what is due soon and what is stale. */
492
- async uiOverview() {
603
+ /**
604
+ * Counts over the caller's visible pages, by team, with what is due soon and what is stale.
605
+ * `list_limit` keeps a big repo's overview short: the first of each list, the most pressing
606
+ * first, with `due_soon_total` and `stale_total`. Only the pages returned are named, so only
607
+ * those taint the session; the counts above the lists name no page.
608
+ */
609
+ async uiOverview(args = {}) {
493
610
  const who = await this.identity.get();
494
611
  this.permit("ui_overview", who);
495
612
  const repo = this.current();
496
613
  const visible = visiblePages(repo, who);
497
614
  const pages = visible.map((p) => ({ page: p, s: this.summary(p) }));
615
+ // A deprecated page is retired, not up for review: like coverage and stale, the review
616
+ // counts leave it out, so they agree with the stale list below.
617
+ const sla = (xs, state) => xs.filter((x) => x.s.status !== "deprecated" && x.s.sla_state === state);
498
618
  const tally = (xs) => ({
499
619
  pages: xs.length,
500
620
  citable: xs.filter((x) => x.s.citable).length,
501
- overdue: xs.filter((x) => x.s.sla_state === "overdue").length,
502
- due_soon: xs.filter((x) => x.s.sla_state === "due_soon").length,
621
+ overdue: sla(xs, "overdue").length,
622
+ due_soon: sla(xs, "due_soon").length,
503
623
  });
504
- const dueSoon = pages.filter((x) => x.s.sla_state === "due_soon").sort((a, b) => (a.s.due ?? "").localeCompare(b.s.due ?? ""));
624
+ const dueSoon = sla(pages, "due_soon").sort((a, b) => (a.s.due ?? "").localeCompare(b.s.due ?? ""));
505
625
  const stale = listStale(visible, repo, this.now());
506
- this.taint([...dueSoon.map((x) => x.page), ...stale.map((e) => repo.byId.get(e.id))]);
626
+ const first = (xs) => (args.list_limit === undefined ? xs : xs.slice(0, args.list_limit));
627
+ const dueShown = first(dueSoon);
628
+ const staleShown = first(stale);
629
+ this.taint([...dueShown.map((x) => x.page), ...staleShown.map((e) => repo.byId.get(e.id))]);
507
630
  return {
508
631
  as_of: formatDate(utcToday(this.now())),
509
632
  totals: { ...tally(pages), draft: pages.filter((x) => x.s.status === "draft").length, deprecated: pages.filter((x) => x.s.status === "deprecated").length },
510
633
  teams: [...new Set(pages.map((x) => x.s.owner_team))].sort().map((team) => ({ team, ...tally(pages.filter((x) => x.s.owner_team === team)) })),
511
- due_soon: dueSoon.map((x) => x.s),
512
- stale,
634
+ due_soon: dueShown.map((x) => x.s),
635
+ stale: staleShown,
636
+ due_soon_total: dueSoon.length,
637
+ stale_total: stale.length,
638
+ // Files under the knowledge root that aren't valid pages, which nothing here serves: a
639
+ // count only, of every audience, never a path or a name (the owner's call, 2026-11-12;
640
+ // anyone this server answers can read the repository's files anyway). It's what
641
+ // loadRepo walked, so exactly what `docsworth lint` lists as frontmatter errors; like
642
+ // every page and lint since the first loader, it skips dot-directories (walkMarkdown).
643
+ skipped_files: repo.invalid.length,
513
644
  };
514
645
  }
515
646
  /**
@@ -526,7 +657,10 @@ export class KnowledgeService {
526
657
  // The links in what the caller is shown: redaction can drop a whole line, and a link to
527
658
  // a visible page on it with it, which then names nothing and taints nothing.
528
659
  const linked = new Map();
529
- for (const link of extractLinks(shown.content)) {
660
+ // The loader's links, and CommonMark's (link text with brackets in it, say): every page
661
+ // either names is linked, and taints, as before.
662
+ const fromCommonMark = pageLinks(shown.content).filter((d) => d && !EXTERNAL.test(d)).map(linkPath);
663
+ for (const link of new Set([...extractLinks(shown.content), ...fromCommonMark])) {
530
664
  const target = repo.byPath.get(resolveLink(page.path, link));
531
665
  if (target && target !== page && canSee(target, who))
532
666
  linked.set(target.path, target);
@@ -538,25 +672,49 @@ export class KnowledgeService {
538
672
  * Visible pages and the links between them. A link to a page the caller can't see is no
539
673
  * edge; `has_restricted_links` says a page has one, exactly as its `read` text shows a
540
674
  * placeholder for it, so the graph reveals nothing `read` doesn't.
675
+ *
676
+ * `max_nodes` keeps a big repo drawable: past it, the most-linked pages (links between
677
+ * visible pages only; ties by id) and the links among them. `total` says how many it
678
+ * chose from, and `teams` how many visible pages each team has, a count that names no
679
+ * page. Only the returned pages are named, so only those taint the session.
541
680
  */
542
681
  async uiGraph(args = {}) {
543
682
  const who = await this.identity.get();
544
683
  this.permit("ui_graph", who);
545
684
  const repo = this.current();
685
+ const all = visiblePages(repo, who);
546
686
  // One team's pages, for its page in the app: only those are named, so only those taint.
547
- const visible = visiblePages(repo, who).filter((p) => !args.owner_team || p.owner_team === args.owner_team);
548
- const seen = new Set(visible);
549
- const edges = new Map();
550
- for (const p of visible)
551
- for (const link of p.links) {
552
- const target = repo.byPath.get(resolveLink(p.path, link));
553
- if (target && target !== p && seen.has(target))
554
- edges.set(`${p.id}\0${target.id}`, { from: p.id, to: target.id });
555
- }
556
- this.taint(visible);
687
+ const visible = all.filter((p) => !args.owner_team || p.owner_team === args.owner_team);
688
+ const linked = (among) => {
689
+ const edges = new Map();
690
+ for (const p of among)
691
+ for (const link of p.links) {
692
+ const target = repo.byPath.get(resolveLink(p.path, link));
693
+ if (target && target !== p && among.has(target))
694
+ edges.set(`${p.id}\0${target.id}`, { from: p, to: target });
695
+ }
696
+ return [...edges.values()];
697
+ };
698
+ let shown = visible;
699
+ let edges = linked(new Set(visible));
700
+ if (args.max_nodes !== undefined && visible.length > args.max_nodes) {
701
+ const degree = new Map();
702
+ for (const e of edges)
703
+ for (const p of [e.from, e.to])
704
+ degree.set(p, (degree.get(p) ?? 0) + 1);
705
+ shown = [...visible].sort((a, b) => (degree.get(b) ?? 0) - (degree.get(a) ?? 0) || a.id.localeCompare(b.id)).slice(0, args.max_nodes);
706
+ const kept = new Set(shown);
707
+ edges = edges.filter((e) => kept.has(e.from) && kept.has(e.to));
708
+ }
709
+ const teams = new Map();
710
+ for (const p of all)
711
+ teams.set(p.owner_team, (teams.get(p.owner_team) ?? 0) + 1);
712
+ this.taint(shown);
557
713
  return {
558
- nodes: visible.map((p) => ({ ...this.summary(p), has_restricted_links: this.pageText(p, who).includes(RESTRICTED) })),
559
- edges: [...edges.values()],
714
+ nodes: shown.map((p) => ({ ...this.summary(p), has_restricted_links: this.pageText(p, who).includes(RESTRICTED) })),
715
+ edges: edges.map((e) => ({ from: e.from.id, to: e.to.id })),
716
+ total: visible.length,
717
+ teams: [...teams].sort(([a], [b]) => a.localeCompare(b)).map(([team, pages]) => ({ team, pages })),
560
718
  };
561
719
  }
562
720
  /** What every write needs first: the policy, a live identity, a GitHub repo. */
@@ -1,4 +1,5 @@
1
1
  import { pageStatus } from "./gate.js";
2
+ const daysAgo = (n) => `${n} ${n === 1 ? "day" : "days"} ago`;
2
3
  /**
3
4
  * Pages past their SLA or without a resolvable team owner (§4.2 `stale`). Deprecated
4
5
  * pages are retired and never reported. Callers pass an ACL-filtered page list.
@@ -15,7 +16,7 @@ export function listStale(pages, repo, now, ownerTeam) {
15
16
  if (!s.owned)
16
17
  out.push({ ...base, reason: "unowned", detail: s.ownership_problem ?? "unowned" });
17
18
  else if (s.sla.state === "overdue")
18
- out.push({ ...base, reason: "overdue", detail: `due ${s.sla.due}, ${-(s.sla.days_until_due ?? 0)} days ago` });
19
+ out.push({ ...base, reason: "overdue", detail: `due ${s.sla.due}, ${daysAgo(-(s.sla.days_until_due ?? 0))}` });
19
20
  else if (s.sla.state === "unverified")
20
21
  out.push({ ...base, reason: "unverified", detail: "no valid last_verified" });
21
22
  }
@@ -17,6 +17,9 @@ export const FIELD_CHECKS = {
17
17
  acl: (v) => typeof v === "string" && SLUGISH.test(v),
18
18
  polish: (v) => typeof v === "boolean",
19
19
  limit: (v) => typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 1000,
20
+ max_nodes: (v) => typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 500,
21
+ list_limit: (v) => typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 1000,
22
+ snippets: (v) => v === "markdown" || v === "text",
20
23
  };
21
24
  const ids = (xs) => (Array.isArray(xs) ? xs.map((x) => String(x?.id)) : []);
22
25
  const prUrl = (sc) => (typeof sc.pr_url === "string" ? { pr_url: sc.pr_url } : {});
@@ -26,7 +29,7 @@ export const TOOLS = [
26
29
  surface: "all",
27
30
  write: false,
28
31
  policy: "search",
29
- auditFields: ["type", "owner_team", "applies_to", "include_stale", "limit"],
32
+ auditFields: ["type", "owner_team", "applies_to", "include_stale", "limit", "snippets"],
30
33
  // Mirrored pages were shown too (as context), so they count as returned.
31
34
  returned: (sc) => ({ returned: [...ids(sc.results), ...ids(sc.mirrored)], excluded: ids(sc.excluded) }),
32
35
  },
@@ -55,8 +58,8 @@ export const TOOLS = [
55
58
  // The web app's views, on `docsworth serve` sessions only. They show page lists, so they
56
59
  // answer to `list`: a policy that refuses `list` refuses them too.
57
60
  { name: "ui_capabilities", surface: "http", write: false, policy: "list", auditFields: [], returned: () => ({}) },
58
- { name: "ui_overview", surface: "http", write: false, policy: "list", auditFields: [], returned: (sc) => ({ returned: [...ids(sc.due_soon), ...ids(sc.stale)] }) },
59
- { name: "ui_graph", surface: "http", write: false, policy: "list", auditFields: ["owner_team"], returned: (sc) => ({ returned: ids(sc.nodes) }) },
61
+ { name: "ui_overview", surface: "http", write: false, policy: "list", auditFields: ["list_limit"], returned: (sc) => ({ returned: [...ids(sc.due_soon), ...ids(sc.stale)] }) },
62
+ { name: "ui_graph", surface: "http", write: false, policy: "list", auditFields: ["owner_team", "max_nodes"], returned: (sc) => ({ returned: ids(sc.nodes) }) },
60
63
  // A page for the app: `read`'s view plus the ids of the visible pages it links to.
61
64
  {
62
65
  name: "ui_page",
@@ -0,0 +1,19 @@
1
+ import { UserError } from "./errors.js";
2
+ export declare class VerifyError extends UserError {
3
+ }
4
+ export interface VerifyResult {
5
+ dryRun: boolean;
6
+ /** Pages verify changed (or would, on a dry run). */
7
+ verified: string[];
8
+ /** Pages already canonical and verified today. */
9
+ unchanged: string[];
10
+ /** Pages, and symbolic links, it left as they are, and why. */
11
+ left: {
12
+ path: string;
13
+ reason: string;
14
+ }[];
15
+ }
16
+ export declare function verifyPages(root: string, paths: string[], opts: {
17
+ now: Date;
18
+ dryRun?: boolean;
19
+ }): VerifyResult;