@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.
- package/README.md +60 -8
- package/dist/cli.js +52 -3
- package/dist/core/acl.d.ts +9 -0
- package/dist/core/acl.js +40 -6
- package/dist/core/adopt.d.ts +68 -0
- package/dist/core/adopt.js +665 -0
- package/dist/core/config.d.ts +6 -1
- package/dist/core/config.js +46 -2
- package/dist/core/edits.d.ts +13 -0
- package/dist/core/edits.js +20 -0
- package/dist/core/frontmatter.d.ts +12 -0
- package/dist/core/frontmatter.js +16 -7
- package/dist/core/gfm.d.ts +13 -0
- package/dist/core/gfm.js +244 -0
- package/dist/core/links.d.ts +82 -0
- package/dist/core/links.js +345 -0
- package/dist/core/lint.d.ts +1 -2
- package/dist/core/lint.js +40 -6
- package/dist/core/mcp/server.js +11 -2
- package/dist/core/printable.d.ts +4 -0
- package/dist/core/printable.js +16 -0
- package/dist/core/references.d.ts +38 -3
- package/dist/core/references.js +543 -41
- package/dist/core/repo.d.ts +4 -0
- package/dist/core/repo.js +3 -1
- package/dist/core/schema.d.ts +4 -0
- package/dist/core/schema.js +8 -6
- package/dist/core/search.d.ts +8 -0
- package/dist/core/search.js +34 -0
- package/dist/core/serve/server.d.ts +1 -2
- package/dist/core/serve/sessions.d.ts +5 -4
- package/dist/core/serve/sessions.js +102 -29
- package/dist/core/service.d.ts +118 -20
- package/dist/core/service.js +249 -91
- package/dist/core/stale.js +2 -1
- package/dist/core/tool-registry.js +6 -3
- package/dist/core/verify-pages.d.ts +19 -0
- package/dist/core/verify-pages.js +110 -0
- package/dist/core/version.d.ts +2 -2
- package/dist/core/version.js +2 -2
- package/dist/web/assets/index-8J-FUbb0.js +29 -0
- package/dist/web/assets/index-BGpAZzeM.css +1 -0
- package/dist/web/index.html +2 -2
- package/docs/format-spec.md +40 -2
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/bin/docsworth-mcp.mjs +1 -1
- package/dist/web/assets/index-BglLMIyN.css +0 -1
- package/dist/web/assets/index-DugFPoRu.js +0 -25
package/dist/core/service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
53
|
-
now;
|
|
54
|
-
repo;
|
|
55
|
-
index;
|
|
59
|
+
loaded = null;
|
|
56
60
|
dirty = false;
|
|
57
|
-
/**
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
this.
|
|
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
|
-
|
|
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 =
|
|
85
|
-
|
|
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,
|
|
107
|
+
body: redactHiddenLinks(redactLinks(p.body, p.path, repo, publicView), teamMirror, RESTRICTED),
|
|
90
108
|
keywords: [p.id, ...(p.applies_to ?? [])],
|
|
91
109
|
})));
|
|
92
|
-
|
|
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.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (this.
|
|
98
|
-
this.
|
|
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 =
|
|
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.
|
|
254
|
+
/** Reload on the next call. */
|
|
137
255
|
invalidate() {
|
|
138
|
-
this.
|
|
256
|
+
this.loader.invalidate();
|
|
139
257
|
}
|
|
140
258
|
/** Mark the index dirty on any change under the knowledge root. */
|
|
141
259
|
watch() {
|
|
142
|
-
|
|
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.
|
|
156
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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:
|
|
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.
|
|
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
|
-
/**
|
|
492
|
-
|
|
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
|
|
502
|
-
due_soon: xs
|
|
621
|
+
overdue: sla(xs, "overdue").length,
|
|
622
|
+
due_soon: sla(xs, "due_soon").length,
|
|
503
623
|
});
|
|
504
|
-
const dueSoon = pages
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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:
|
|
559
|
-
edges:
|
|
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. */
|
package/dist/core/stale.js
CHANGED
|
@@ -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)}
|
|
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;
|