@docsworth/cli 0.0.0-stage → 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/LICENSE +55 -0
- package/README.md +238 -2
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +458 -0
- package/dist/core/acl.d.ts +37 -0
- package/dist/core/acl.js +136 -0
- package/dist/core/adopt.d.ts +68 -0
- package/dist/core/adopt.js +665 -0
- package/dist/core/audit-log.d.ts +133 -0
- package/dist/core/audit-log.js +290 -0
- package/dist/core/blocks.d.ts +35 -0
- package/dist/core/blocks.js +417 -0
- package/dist/core/codeowners.d.ts +62 -0
- package/dist/core/codeowners.js +133 -0
- package/dist/core/config.d.ts +101 -0
- package/dist/core/config.js +175 -0
- package/dist/core/coverage.d.ts +25 -0
- package/dist/core/coverage.js +41 -0
- package/dist/core/duration.d.ts +20 -0
- package/dist/core/duration.js +60 -0
- package/dist/core/edits.d.ts +13 -0
- package/dist/core/edits.js +20 -0
- package/dist/core/errors.d.ts +11 -0
- package/dist/core/errors.js +13 -0
- package/dist/core/frontmatter.d.ts +29 -0
- package/dist/core/frontmatter.js +49 -0
- package/dist/core/gate.d.ts +17 -0
- package/dist/core/gate.js +25 -0
- package/dist/core/gfm.d.ts +13 -0
- package/dist/core/gfm.js +244 -0
- package/dist/core/github.d.ts +152 -0
- package/dist/core/github.js +234 -0
- package/dist/core/host.d.ts +125 -0
- package/dist/core/host.js +22 -0
- package/dist/core/identity.d.ts +39 -0
- package/dist/core/identity.js +127 -0
- package/dist/core/init.d.ts +25 -0
- package/dist/core/init.js +86 -0
- package/dist/core/links.d.ts +82 -0
- package/dist/core/links.js +345 -0
- package/dist/core/lint.d.ts +16 -0
- package/dist/core/lint.js +149 -0
- package/dist/core/mcp/audit-transport.d.ts +16 -0
- package/dist/core/mcp/audit-transport.js +238 -0
- package/dist/core/mcp/server.d.ts +19 -0
- package/dist/core/mcp/server.js +216 -0
- package/dist/core/mcp/text.d.ts +14 -0
- package/dist/core/mcp/text.js +43 -0
- package/dist/core/mirror-view.d.ts +68 -0
- package/dist/core/mirror-view.js +367 -0
- package/dist/core/new-page.d.ts +52 -0
- package/dist/core/new-page.js +49 -0
- package/dist/core/policy.d.ts +10 -0
- package/dist/core/policy.js +30 -0
- package/dist/core/polish.d.ts +42 -0
- package/dist/core/polish.js +1000 -0
- package/dist/core/printable.d.ts +4 -0
- package/dist/core/printable.js +16 -0
- package/dist/core/references.d.ts +87 -0
- package/dist/core/references.js +1744 -0
- package/dist/core/repo.d.ts +46 -0
- package/dist/core/repo.js +118 -0
- package/dist/core/schema.d.ts +50 -0
- package/dist/core/schema.js +51 -0
- package/dist/core/search.d.ts +32 -0
- package/dist/core/search.js +132 -0
- package/dist/core/serve/auth.d.ts +23 -0
- package/dist/core/serve/auth.js +38 -0
- package/dist/core/serve/server.d.ts +21 -0
- package/dist/core/serve/server.js +138 -0
- package/dist/core/serve/sessions.d.ts +40 -0
- package/dist/core/serve/sessions.js +203 -0
- package/dist/core/serve/token.d.ts +10 -0
- package/dist/core/serve/token.js +96 -0
- package/dist/core/service.d.ts +473 -0
- package/dist/core/service.js +973 -0
- package/dist/core/sla.d.ts +16 -0
- package/dist/core/sla.js +20 -0
- package/dist/core/stale.d.ts +16 -0
- package/dist/core/stale.js +24 -0
- package/dist/core/tool-registry.d.ts +31 -0
- package/dist/core/tool-registry.js +80 -0
- package/dist/core/url.d.ts +4 -0
- package/dist/core/url.js +31 -0
- package/dist/core/verify-pages.d.ts +19 -0
- package/dist/core/verify-pages.js +110 -0
- package/dist/core/version.d.ts +22 -0
- package/dist/core/version.js +32 -0
- package/dist/core/writes.d.ts +58 -0
- package/dist/core/writes.js +113 -0
- package/dist/web/assets/index-8J-FUbb0.js +29 -0
- package/dist/web/assets/index-BGpAZzeM.css +1 -0
- package/dist/web/index.html +14 -0
- package/docs/format-spec.md +349 -0
- package/package.json +75 -4
- package/plugin/.claude-plugin/plugin.json +8 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/bin/docsworth-mcp.mjs +46 -0
- package/plugin/skills/draft-runbook/SKILL.md +75 -0
- package/plugin/skills/polish-page/SKILL.md +64 -0
- package/plugin/skills/update-after-incident/SKILL.md +59 -0
- package/plugin/skills/verify-page/SKILL.md +46 -0
- package/templates/github/knowledge.yml +51 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { userInfo } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { adoptRepo } from "./core/adopt.js";
|
|
9
|
+
import { actorOf, createAuditLog, stateDir } from "./core/audit-log.js";
|
|
10
|
+
import { CONFIG_PATH, ConfigError, loadConfig } from "./core/config.js";
|
|
11
|
+
import { computeCoverage, formatCoverage } from "./core/coverage.js";
|
|
12
|
+
import { parseDate } from "./core/duration.js";
|
|
13
|
+
import { IdentityProvider } from "./core/identity.js";
|
|
14
|
+
import { githubRemote, initRepo } from "./core/init.js";
|
|
15
|
+
import { formatDiagnostics, lintRepo } from "./core/lint.js";
|
|
16
|
+
import { auditTransport } from "./core/mcp/audit-transport.js";
|
|
17
|
+
import { createMcpServer } from "./core/mcp/server.js";
|
|
18
|
+
import { policySummary } from "./core/policy.js";
|
|
19
|
+
import { loadRepo } from "./core/repo.js";
|
|
20
|
+
import { EXTENSION_ID } from "./core/serve/auth.js";
|
|
21
|
+
import { DEFAULT_PORT, startServer } from "./core/serve/server.js";
|
|
22
|
+
import { loadToken } from "./core/serve/token.js";
|
|
23
|
+
import { KnowledgeService } from "./core/service.js";
|
|
24
|
+
import { listStale } from "./core/stale.js";
|
|
25
|
+
import { printable, printableJson } from "./core/printable.js";
|
|
26
|
+
import { verifyPages } from "./core/verify-pages.js";
|
|
27
|
+
import { PACKAGE_VERSION } from "./core/version.js";
|
|
28
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
29
|
+
import { parse as parseYaml } from "yaml";
|
|
30
|
+
// Pro loads lazily through `pro()` below; the CLI knows it only by core's contract.
|
|
31
|
+
import { isUserError } from "./core/errors.js";
|
|
32
|
+
import { HOST_API } from "./core/host.js";
|
|
33
|
+
const program = new Command()
|
|
34
|
+
.name("docsworth")
|
|
35
|
+
.description("Agent-ready knowledge: team-owned pages, review SLAs, a citation gate for agents.")
|
|
36
|
+
.version(PACKAGE_VERSION)
|
|
37
|
+
.option("--as-of <date>", "evaluate SLAs as of YYYY-MM-DD instead of today", (v) => {
|
|
38
|
+
if (!parseDate(v))
|
|
39
|
+
throw new InvalidArgumentError("expected YYYY-MM-DD");
|
|
40
|
+
return v;
|
|
41
|
+
});
|
|
42
|
+
/** "Now" for every SLA computation; --as-of pins it for reproducible reports. */
|
|
43
|
+
export function now() {
|
|
44
|
+
const asOf = program.opts().asOf;
|
|
45
|
+
return asOf ? parseDate(asOf) : new Date();
|
|
46
|
+
}
|
|
47
|
+
const repoAt = (dir) => loadRepo(resolve(dir ?? "."));
|
|
48
|
+
/**
|
|
49
|
+
* The change happened but its outcome isn't in a required log: say so, and exit 3 (done,
|
|
50
|
+
* audit degraded) so automation can tell it from success (0) and failure (2).
|
|
51
|
+
*/
|
|
52
|
+
export const EXIT_AUDIT_DEGRADED = 3;
|
|
53
|
+
function auditDegraded(err, what) {
|
|
54
|
+
console.error(`docsworth: ${err.message} (${what}; exit ${EXIT_AUDIT_DEGRADED}: the audit outcome wasn't recorded)`);
|
|
55
|
+
process.exitCode = EXIT_AUDIT_DEGRADED;
|
|
56
|
+
}
|
|
57
|
+
/** Who ran a local CLI command, for audit events that have no GitHub identity. */
|
|
58
|
+
const cliActor = () => ({ login: userInfo().username, mode: "local", teams: [] });
|
|
59
|
+
/**
|
|
60
|
+
* Docsworth pro is its own package (LICENSE.md in it), installed beside this one: globally
|
|
61
|
+
* next to the global CLI, or in the same project. The CLI finds it by name, like any
|
|
62
|
+
* dependency, and knows it only by core's contract (src/core/host.ts).
|
|
63
|
+
*/
|
|
64
|
+
const PRO_PACKAGE = "@docsworth-pro/pro";
|
|
65
|
+
/** True when pro isn't installed at all. A file missing *inside* an installed pro is a real error. */
|
|
66
|
+
function isMissingPro(err) {
|
|
67
|
+
if (err?.code !== "ERR_MODULE_NOT_FOUND")
|
|
68
|
+
return false;
|
|
69
|
+
try {
|
|
70
|
+
import.meta.resolve(`${PRO_PACKAGE}/package.json`);
|
|
71
|
+
return false; // pro is there, so what's missing is inside it
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
return e.code === "ERR_MODULE_NOT_FOUND";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** "Pro isn't installed." Only pro() throws it; it prints like any ConfigError. */
|
|
78
|
+
class ProMissing extends ConfigError {
|
|
79
|
+
}
|
|
80
|
+
/** Pro is installed but can't be used: it doesn't load, or it was built for another CLI. */
|
|
81
|
+
class ProUnusable extends ConfigError {
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether pro can be used, decided once: missing, unusable (why), or ok. Pro declares the
|
|
85
|
+
* version of core's contract it was built for (src/core/host.ts, HOST_API); one built for
|
|
86
|
+
* another is refused here, by name, rather than failing somewhere inside a command.
|
|
87
|
+
*/
|
|
88
|
+
let proState;
|
|
89
|
+
const proStatus = () => (proState ??= import(`${PRO_PACKAGE}/host-api`).then(({ PRO_HOST_API }) => PRO_HOST_API === HOST_API ? { ok: true } : { unusable: `it was built for host API ${String(PRO_HOST_API)}, and this CLI has ${HOST_API}; install versions that match` }, (err) => (isMissingPro(err) ? { missing: true } : { unusable: `it doesn't load: ${err.message}` })));
|
|
90
|
+
/**
|
|
91
|
+
* Pro is a separate package, so the core CLI runs without it and a pro command says what's
|
|
92
|
+
* missing. Once pro is known to be usable, a failure loading a module of it is pro's own: a
|
|
93
|
+
* file missing inside it is a real error, reported as it is.
|
|
94
|
+
*/
|
|
95
|
+
async function pro(feature, load) {
|
|
96
|
+
const state = await proStatus();
|
|
97
|
+
if ("missing" in state)
|
|
98
|
+
throw new ProMissing(`${feature} is part of Docsworth pro, which isn't installed`);
|
|
99
|
+
if ("unusable" in state)
|
|
100
|
+
throw new ProUnusable(`${feature} needs Docsworth pro, which is installed but can't be used: ${state.unusable}`);
|
|
101
|
+
return load();
|
|
102
|
+
}
|
|
103
|
+
/** Pro's commands (src/core/host.ts, ProCommands), once pro is known to be usable. */
|
|
104
|
+
const proCommands = (feature) => pro(feature, async () => (await import(`${PRO_PACKAGE}/commands`)).commands);
|
|
105
|
+
/**
|
|
106
|
+
* Without pro, an audit_http block can't be fully validated, so only the plainly optional
|
|
107
|
+
* shape counts as optional: an object naming its `url_env`, with `required` absent or false.
|
|
108
|
+
* Anything else (required, malformed `required`, a non-object, no `url_env`) is treated as
|
|
109
|
+
* required, which fails closed as httpSinkFromRepo does for a required or invalid block.
|
|
110
|
+
*/
|
|
111
|
+
function isOptionalSink(block) {
|
|
112
|
+
// A non-object has no url_env string, so the url_env test below already rejects it.
|
|
113
|
+
const { url_env, required } = Object(block);
|
|
114
|
+
return typeof url_env === "string" && url_env.length > 0 && (required === undefined || required === false);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The pro HTTP audit sink, if `audit_http` is configured. Without pro the sink can't be set
|
|
118
|
+
* up, which is handled as httpSinkFromRepo handles any sink that can't start: fatal when
|
|
119
|
+
* required, otherwise a warning and no sink.
|
|
120
|
+
*/
|
|
121
|
+
async function httpSink(root) {
|
|
122
|
+
const file = join(root, CONFIG_PATH);
|
|
123
|
+
const raw = existsSync(file) ? parseYaml(readFileSync(file, "utf8"), { schema: "core" }) : null;
|
|
124
|
+
const block = raw?.audit_http;
|
|
125
|
+
if (block === undefined || block === null)
|
|
126
|
+
return null;
|
|
127
|
+
let commands;
|
|
128
|
+
try {
|
|
129
|
+
commands = await proCommands("audit_http");
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
if (!(err instanceof ProMissing) || !isOptionalSink(block))
|
|
133
|
+
throw err;
|
|
134
|
+
process.stderr.write(`docsworth: audit collector disabled for this run: ${err.message}\n`);
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return commands.httpSinkFromRepo(root);
|
|
138
|
+
}
|
|
139
|
+
/** The audit log for this repo: the core file sink plus the pro HTTP sink, if configured. */
|
|
140
|
+
async function openAuditLog(root, config) {
|
|
141
|
+
const http = await httpSink(root);
|
|
142
|
+
return createAuditLog(root, config, http ? [http] : [], { now });
|
|
143
|
+
}
|
|
144
|
+
/** What pro's commands get from this CLI (src/core/host.ts, CommandContext). */
|
|
145
|
+
const ctx = { now, repoAt, cliActor, openAuditLog, auditDegraded, requireEnv };
|
|
146
|
+
program
|
|
147
|
+
.command("init")
|
|
148
|
+
.description("scaffold a knowledge repo here: config, CODEOWNERS, root map and a writing guide (never overwrites)")
|
|
149
|
+
.argument("[dir]", "repo root", ".")
|
|
150
|
+
.option("--org <org>", "GitHub org owning the teams (default: from the origin remote)")
|
|
151
|
+
.option("--team <team>", "team that owns knowledge/ to start with", "docs")
|
|
152
|
+
.option("--repo <owner/name>", "GitHub repo; enables GitHub ACLs and PR tools (default: from the origin remote)")
|
|
153
|
+
.option("--local", "do not set github.repo even if a GitHub remote exists")
|
|
154
|
+
.option("--ci", "also add .github/workflows/knowledge.yml")
|
|
155
|
+
.action((dir, opts) => {
|
|
156
|
+
const root = resolve(dir);
|
|
157
|
+
const remote = githubRemote(root);
|
|
158
|
+
const org = opts.org ?? remote?.owner;
|
|
159
|
+
if (!org)
|
|
160
|
+
throw new ConfigError("no GitHub remote found; pass --org <org>");
|
|
161
|
+
const repo = opts.local ? undefined : (opts.repo ?? (remote ? `${remote.owner}/${remote.name}` : undefined));
|
|
162
|
+
const r = initRepo(root, { org, team: opts.team, repo, ci: opts.ci, now: now() });
|
|
163
|
+
for (const f of r.created)
|
|
164
|
+
console.log(`created ${f}`);
|
|
165
|
+
for (const f of r.skipped)
|
|
166
|
+
console.log(`exists ${f}`);
|
|
167
|
+
for (const h of r.hints)
|
|
168
|
+
console.log(`hint ${h}`);
|
|
169
|
+
console.log(`\nNext: docsworth lint ${dir === "." ? "." : dir}, then install the Claude Code plugin (see README).`);
|
|
170
|
+
});
|
|
171
|
+
program
|
|
172
|
+
.command("lint")
|
|
173
|
+
.description("validate frontmatter, CODEOWNERS ownership and links; exits 1 on errors")
|
|
174
|
+
.argument("[dir]", "knowledge repo root", ".")
|
|
175
|
+
.option("--json", "machine-readable output")
|
|
176
|
+
.action((dir, opts) => {
|
|
177
|
+
const result = lintRepo(repoAt(dir), now());
|
|
178
|
+
console.log(opts.json ? printableJson(result) : formatDiagnostics(result));
|
|
179
|
+
process.exitCode = result.errors > 0 ? 1 : 0;
|
|
180
|
+
});
|
|
181
|
+
program
|
|
182
|
+
.command("adopt")
|
|
183
|
+
.description("give existing markdown pages the frontmatter Docsworth needs, as drafts, and turn route links into .md links (working tree only)")
|
|
184
|
+
.argument("[dir]", "knowledge repo root", ".")
|
|
185
|
+
.option("--team <team>", "owning team for pages CODEOWNERS gives no single team")
|
|
186
|
+
.option("--review-every <period>", "review period for pages without one", "90d")
|
|
187
|
+
.option("--dry-run", "report what would change; write nothing")
|
|
188
|
+
.option("--json", "machine-readable output")
|
|
189
|
+
.action((dir, opts) => {
|
|
190
|
+
const r = adoptRepo(resolve(dir), { now: now(), team: opts.team, reviewEvery: opts.reviewEvery, dryRun: opts.dryRun });
|
|
191
|
+
if (opts.json)
|
|
192
|
+
return console.log(printableJson(r));
|
|
193
|
+
// Paths, links, keys and reasons quote what pages hold: printed escaped.
|
|
194
|
+
const say = (line) => console.log(printable(line));
|
|
195
|
+
for (const l of r.left)
|
|
196
|
+
say(`left ${l.path}: ${l.reason}`);
|
|
197
|
+
for (const l of r.linksLeft)
|
|
198
|
+
say(`link left ${l.path}: \`${l.link}\` ${l.why}`);
|
|
199
|
+
if (r.extraFields.length)
|
|
200
|
+
say(`config frontmatter.extra_fields gains ${r.extraFields.join(", ")}`);
|
|
201
|
+
for (const h of r.hints)
|
|
202
|
+
say(`hint ${h}`);
|
|
203
|
+
const n = (count, what) => `${count} ${what}${count === 1 ? "" : "s"}`;
|
|
204
|
+
console.log(`\n${r.dryRun ? "Would adopt" : "Adopted"} ${n(r.adopted.length, "page")} (${r.unchanged.length} unchanged, ${r.left.length} left); ` +
|
|
205
|
+
`${r.linksRewritten} ${r.linksRewritten === 1 ? "link" : "links"} rewritten, ${r.linksLeft.length} left.`);
|
|
206
|
+
console.log(r.dryRun
|
|
207
|
+
? "Dry run: nothing written."
|
|
208
|
+
: `Nothing is committed. Review the diff, run \`docsworth lint ${dir}\`, and open a pull request: adopted pages are drafts, cited once their team runs \`docsworth verify\`.`);
|
|
209
|
+
});
|
|
210
|
+
program
|
|
211
|
+
.command("verify")
|
|
212
|
+
.description("verify pages: each draft or canonical page under the paths becomes canonical, verified today (working tree only)")
|
|
213
|
+
.argument("<paths...>", "pages, or directories of them")
|
|
214
|
+
.option("--dir <dir>", "knowledge repo root", ".")
|
|
215
|
+
.option("--dry-run", "report what would change; write nothing")
|
|
216
|
+
.option("--json", "machine-readable output")
|
|
217
|
+
.action((paths, opts) => {
|
|
218
|
+
// Paths are the shell's, relative to where it runs, whatever --dir says.
|
|
219
|
+
const r = verifyPages(resolve(opts.dir), paths.map((p) => resolve(p)), { now: now(), dryRun: opts.dryRun });
|
|
220
|
+
if (opts.json)
|
|
221
|
+
return console.log(printableJson(r));
|
|
222
|
+
for (const l of r.left)
|
|
223
|
+
console.log(printable(`left ${l.path}: ${l.reason}`));
|
|
224
|
+
console.log(`\n${r.dryRun ? "Would verify" : "Verified"} ${r.verified.length} page${r.verified.length === 1 ? "" : "s"} (${r.unchanged.length} already verified today, ${r.left.length} left).`);
|
|
225
|
+
console.log(r.dryRun ? "Dry run: nothing written." : "Nothing is committed: open a pull request, which the owning team reviews.");
|
|
226
|
+
});
|
|
227
|
+
program
|
|
228
|
+
.command("stale")
|
|
229
|
+
.description("pages past their review SLA or without a team owner")
|
|
230
|
+
.argument("[dir]", "knowledge repo root", ".")
|
|
231
|
+
.option("--owner-team <team>", "only this team's pages")
|
|
232
|
+
.option("--json", "machine-readable output")
|
|
233
|
+
.action((dir, opts) => {
|
|
234
|
+
const repo = repoAt(dir);
|
|
235
|
+
const stale = listStale(repo.pages, repo, now(), opts.ownerTeam);
|
|
236
|
+
if (opts.json)
|
|
237
|
+
return console.log(JSON.stringify(stale, null, 2));
|
|
238
|
+
for (const s of stale)
|
|
239
|
+
console.log(`${s.reason.padEnd(10)} ${s.id.padEnd(30)} @${s.owner_team.padEnd(10)} ${s.detail}`);
|
|
240
|
+
console.log(`${stale.length} stale or unowned pages`);
|
|
241
|
+
});
|
|
242
|
+
program
|
|
243
|
+
.command("coverage")
|
|
244
|
+
.description("ownership coverage: % of pages team-owned and % verified within SLA, by team")
|
|
245
|
+
.argument("[dir]", "knowledge repo root", ".")
|
|
246
|
+
.option("--json", "machine-readable output")
|
|
247
|
+
.option("--html <file>", "also write a static dashboard")
|
|
248
|
+
.action(async (dir, opts) => {
|
|
249
|
+
const repo = repoAt(dir);
|
|
250
|
+
const c = computeCoverage(repo, now());
|
|
251
|
+
console.log(opts.json ? JSON.stringify(c, null, 2) : formatCoverage(c));
|
|
252
|
+
if (opts.html)
|
|
253
|
+
await (await proCommands("coverage --html")).coverageHtml(ctx, c, repo, opts.html);
|
|
254
|
+
});
|
|
255
|
+
program
|
|
256
|
+
.command("digest")
|
|
257
|
+
.description("weekly owner digest per team: due, overdue, most read by agents; prints unless --send")
|
|
258
|
+
.argument("[dir]", "knowledge repo root", ".")
|
|
259
|
+
.option("--team <team>", "only this team")
|
|
260
|
+
.option("--send", "post to Slack (webhook from digest.slack_webhook_env / team_webhooks)")
|
|
261
|
+
.option("--json", "machine-readable output")
|
|
262
|
+
.action(async (dir, opts) => (await proCommands("digest")).digest(ctx, dir, opts));
|
|
263
|
+
program
|
|
264
|
+
.command("compile")
|
|
265
|
+
.description("compile canonical runbooks into SKILL.md files")
|
|
266
|
+
.argument("[dir]", "knowledge repo root", ".")
|
|
267
|
+
.option("--out <dir>", "output directory (default: skills.out_dir from config)")
|
|
268
|
+
.option("--check", "exit 1 if compiled skills drifted from their source pages")
|
|
269
|
+
.option("--mark-stale", "with --check: mark drifted skills stale in place")
|
|
270
|
+
.option("--dry-run", "report what would compile without writing")
|
|
271
|
+
.option("--include-restricted", "also compile runbooks whose acl is not internal")
|
|
272
|
+
.action(async (dir, opts) => (await proCommands("compile")).compile(ctx, dir, opts));
|
|
273
|
+
program
|
|
274
|
+
.command("mcp")
|
|
275
|
+
.description("serve the knowledge repo to agents over MCP (stdio)")
|
|
276
|
+
.argument("[dir]", "knowledge repo root (default: DOCSWORTH_KNOWLEDGE_DIR, else the current directory)")
|
|
277
|
+
.action(async (dir) => {
|
|
278
|
+
// The plugin launcher passes the repo in the environment: on Windows its command line
|
|
279
|
+
// goes through cmd.exe, which would split a path at a space.
|
|
280
|
+
const root = resolve(dir ?? (process.env.DOCSWORTH_KNOWLEDGE_DIR || "."));
|
|
281
|
+
const config = loadConfig(root);
|
|
282
|
+
const identity = IdentityProvider.fromEnv(config);
|
|
283
|
+
// Fail closed: in GitHub mode, prove the caller's identity before serving anything.
|
|
284
|
+
const who = await identity.fresh();
|
|
285
|
+
const audit = await openAuditLog(root, config);
|
|
286
|
+
// A required audit log that can't record the start refuses to serve at all.
|
|
287
|
+
await audit?.emit({ kind: "server_start", actor: actorOf(who), detail: { config_sha256: configHash(root), mode: who.mode, policy: policySummary(config.policy) } });
|
|
288
|
+
const service = new KnowledgeService(root, identity, now);
|
|
289
|
+
service.watch();
|
|
290
|
+
const stdio = new StdioServerTransport();
|
|
291
|
+
const transport = audit ? auditTransport(stdio, audit, () => identity.get(), {
|
|
292
|
+
includeQueries: config.audit_log.include_queries,
|
|
293
|
+
initialActor: actorOf(who),
|
|
294
|
+
known: () => service.knownValues(),
|
|
295
|
+
snapshot: () => service.snapshot(),
|
|
296
|
+
}) : stdio;
|
|
297
|
+
// The fs watcher keeps the event loop alive. When the client goes away, close it and
|
|
298
|
+
// let in-flight responses drain; the process then exits on its own.
|
|
299
|
+
process.stdin.once("end", () => {
|
|
300
|
+
service.close();
|
|
301
|
+
// The same details as server_start, for the config in force at the end; a stop the
|
|
302
|
+
// log can't take is said on stderr rather than lost silently.
|
|
303
|
+
void audit
|
|
304
|
+
?.emit({ kind: "server_stop", actor: actorOf(who), snapshot: service.snapshot(), detail: { config_sha256: configHash(root), mode: who.mode, policy: policySummary(service.policy()) } })
|
|
305
|
+
.catch((err) => process.stderr.write(`docsworth: couldn't record server_stop: ${err.message}\n`))
|
|
306
|
+
.then(() => audit.flush());
|
|
307
|
+
});
|
|
308
|
+
await createMcpServer(service, { who, whoami: () => identity.get() }).connect(transport);
|
|
309
|
+
process.stderr.write(`docsworth: serving ${root} (${who.mode}${who.login ? ` as ${who.login}` : ""}) over stdio\n`);
|
|
310
|
+
});
|
|
311
|
+
program
|
|
312
|
+
.command("serve")
|
|
313
|
+
.description("serve the knowledge repo to the Docsworth web app and browser extension (MCP over HTTP, on 127.0.0.1 only)")
|
|
314
|
+
.argument("[dir]", "knowledge repo root (default: DOCSWORTH_KNOWLEDGE_DIR, else the current directory)")
|
|
315
|
+
.option("--port <n>", "port on 127.0.0.1", (v) => {
|
|
316
|
+
const n = Number(v);
|
|
317
|
+
if (!Number.isInteger(n) || n < 0 || n > 65535)
|
|
318
|
+
throw new InvalidArgumentError("expected a port number");
|
|
319
|
+
return n;
|
|
320
|
+
}, DEFAULT_PORT)
|
|
321
|
+
.option("--allow-extension <id>", "let this browser extension (its 32-letter id) call the server; repeatable", (v, prev) => {
|
|
322
|
+
if (!EXTENSION_ID.test(v))
|
|
323
|
+
throw new InvalidArgumentError("expected an extension id: 32 letters a to p");
|
|
324
|
+
return [...prev, v];
|
|
325
|
+
}, [])
|
|
326
|
+
.option("--rotate-token", "replace the access token first (pair the extension again afterwards)")
|
|
327
|
+
.option("--print-token", "print the access token, for pairing the extension, and exit")
|
|
328
|
+
.action(async (dir, opts) => {
|
|
329
|
+
const root = resolve(dir ?? (process.env.DOCSWORTH_KNOWLEDGE_DIR || "."));
|
|
330
|
+
const config = loadConfig(root);
|
|
331
|
+
// In this user's state directory, outside the repo: it can't be committed.
|
|
332
|
+
const tokenDir = stateDir(root);
|
|
333
|
+
if (opts.printToken) {
|
|
334
|
+
console.log(loadToken(tokenDir, { rotate: opts.rotateToken }));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const identity = IdentityProvider.fromEnv(config);
|
|
338
|
+
// Fail closed, as `mcp` does: in GitHub mode, prove the identity before listening.
|
|
339
|
+
const who = await identity.fresh();
|
|
340
|
+
const audit = await openAuditLog(root, config);
|
|
341
|
+
await audit?.emit({ kind: "server_start", actor: actorOf(who), detail: { config_sha256: configHash(root), mode: who.mode, policy: policySummary(config.policy), transport: "http" } });
|
|
342
|
+
const token = loadToken(tokenDir, { rotate: opts.rotateToken });
|
|
343
|
+
// The built web app sits next to the built CLI (dist/web); from source there is none.
|
|
344
|
+
const web = join(dirname(fileURLToPath(import.meta.url)), "web");
|
|
345
|
+
const serving = await startServer({
|
|
346
|
+
root,
|
|
347
|
+
identity,
|
|
348
|
+
who,
|
|
349
|
+
audit,
|
|
350
|
+
includeQueries: config.audit_log.include_queries,
|
|
351
|
+
now,
|
|
352
|
+
port: opts.port,
|
|
353
|
+
token,
|
|
354
|
+
extensions: new Set(opts.allowExtension),
|
|
355
|
+
webRoot: existsSync(join(web, "index.html")) ? web : null,
|
|
356
|
+
}).catch((err) => {
|
|
357
|
+
if (err.code === "EADDRINUSE")
|
|
358
|
+
throw new ConfigError(`port ${opts.port} is in use; pick another with --port`);
|
|
359
|
+
throw err;
|
|
360
|
+
});
|
|
361
|
+
// The URL carries the token in its fragment, which the browser never sends to a server.
|
|
362
|
+
process.stderr.write(`docsworth: serving ${root} (${who.mode}${who.login ? ` as ${who.login}` : ""}) at ${serving.url}\n`);
|
|
363
|
+
const stop = async () => {
|
|
364
|
+
await serving.close();
|
|
365
|
+
await audit
|
|
366
|
+
?.emit({ kind: "server_stop", actor: actorOf(who), detail: { config_sha256: configHash(root), mode: who.mode, policy: policySummary(config.policy), transport: "http" } })
|
|
367
|
+
.catch((err) => process.stderr.write(`docsworth: couldn't record server_stop: ${err.message}\n`));
|
|
368
|
+
await audit?.flush();
|
|
369
|
+
process.exit(0);
|
|
370
|
+
};
|
|
371
|
+
for (const s of ["SIGINT", "SIGTERM"])
|
|
372
|
+
process.once(s, () => void stop());
|
|
373
|
+
});
|
|
374
|
+
function configHash(root) {
|
|
375
|
+
return createHash("sha256").update(readFileSync(join(root, ".knowledge/config.yaml"))).digest("hex");
|
|
376
|
+
}
|
|
377
|
+
function requireEnv(name) {
|
|
378
|
+
const v = process.env[name];
|
|
379
|
+
if (!v)
|
|
380
|
+
throw new ConfigError(`${name} is not set (see .env.example)`);
|
|
381
|
+
return v;
|
|
382
|
+
}
|
|
383
|
+
program
|
|
384
|
+
.command("mirror")
|
|
385
|
+
.description("read-only mirror of Confluence/Notion into .knowledge/mirror, scored for freshness and ownership")
|
|
386
|
+
.argument("[dir]", "knowledge repo root", ".")
|
|
387
|
+
.option("--source <name>", "only the source with this name")
|
|
388
|
+
.action(async (dir, opts) => (await proCommands("mirror")).mirror(ctx, dir, opts));
|
|
389
|
+
program
|
|
390
|
+
.command("mirror-purge")
|
|
391
|
+
.description("delete the local mirror cache (all sources, or one); idempotent and audited")
|
|
392
|
+
.argument("[dir]", "knowledge repo root", ".")
|
|
393
|
+
.option("--source <kind>", "only this source, as in mirror ids: confluence or notion")
|
|
394
|
+
.action(async (dir, opts) => (await proCommands("mirror-purge")).mirrorPurge(ctx, dir, opts));
|
|
395
|
+
program
|
|
396
|
+
.command("promote")
|
|
397
|
+
.description("open a PR copying a mirrored page into knowledge/ with frontmatter pre-filled")
|
|
398
|
+
.argument("<mirror-id>", "e.g. mirror:confluence:ENG:12345")
|
|
399
|
+
.argument("[dir]", "knowledge repo root", ".")
|
|
400
|
+
.requiredOption("--owner-team <team>", "team that will own the page (must match CODEOWNERS for its path)")
|
|
401
|
+
.option("--type <type>", "runbook | service | guide | policy", "runbook")
|
|
402
|
+
.option("--id <id>", "page id (default: derived from the title)")
|
|
403
|
+
.option("--dir <dir>", "directory under the knowledge root")
|
|
404
|
+
.option("--dry-run", "print the page instead of opening a PR")
|
|
405
|
+
.action(async (mirrorId, dir, opts) => (await proCommands("promote")).promote(ctx, mirrorId, dir, opts));
|
|
406
|
+
const bench = program.command("bench").description("citation-accuracy benchmark: diy vs ungated vs gated");
|
|
407
|
+
bench
|
|
408
|
+
.command("run")
|
|
409
|
+
.description("answer a question set with Claude under each configuration (spends API credits)")
|
|
410
|
+
.argument("[dir]", "knowledge repo root", ".")
|
|
411
|
+
.option("--questions <file>", "questions YAML (default: <dir>/questions.yaml)")
|
|
412
|
+
.option("--out <dir>", "results directory (default: bench-results/<date>)")
|
|
413
|
+
.option("--systems <list>", "comma-separated subset of diy,ungated,gated (default: all)")
|
|
414
|
+
.option("--model <model>", "Claude model", process.env.DOCSWORTH_BENCH_MODEL || "claude-opus-5-5")
|
|
415
|
+
.option("--limit <n>", "only the first n questions", (v) => Number(v))
|
|
416
|
+
.option("--concurrency <n>", "parallel runs", (v) => Number(v), 4)
|
|
417
|
+
.option("--label <text>", "what this run is", "harness validation only")
|
|
418
|
+
.action(async (dir, opts) => (await proCommands("bench")).benchRun(ctx, dir, opts));
|
|
419
|
+
bench
|
|
420
|
+
.command("score")
|
|
421
|
+
.description("owner-graded answer correctness from a filled-in grading.csv")
|
|
422
|
+
.argument("<results-dir>")
|
|
423
|
+
.action(async (dir) => (await proCommands("bench")).benchScore(ctx, dir));
|
|
424
|
+
program
|
|
425
|
+
.command("audit")
|
|
426
|
+
.description("audit report: ownership coverage, stale and overlapping pages, DIY accuracy score")
|
|
427
|
+
.argument("[dir]", "knowledge repo root", ".")
|
|
428
|
+
.option("--bench <results-dir>", "include a bench run's metrics")
|
|
429
|
+
.option("--out <dir>", "write audit.md and audit.html here", ".")
|
|
430
|
+
.option("--title <text>", "report title", "Knowledge readiness audit")
|
|
431
|
+
.action(async (dir, opts) => (await proCommands("audit")).audit(ctx, dir, opts));
|
|
432
|
+
program
|
|
433
|
+
.command("access-review")
|
|
434
|
+
.description("who can see, verify and approve what: per page, per team, and with --members per person")
|
|
435
|
+
.argument("[dir]", "knowledge repo root", ".")
|
|
436
|
+
.option("--members", "list each team's members through GitHub (your token decides what you see)")
|
|
437
|
+
.option("--format <md|csv>", "markdown report, or CSV of pages (or people, with --members)", "md")
|
|
438
|
+
.action(async (dir, opts) => (await proCommands("access-review")).accessReview(ctx, dir, opts));
|
|
439
|
+
program
|
|
440
|
+
.command("usage-report")
|
|
441
|
+
.description("what agents asked for, from the audit log: by tool and team, outcomes, top and excluded pages")
|
|
442
|
+
.argument("[dir]", "knowledge repo root", ".")
|
|
443
|
+
.option("--since <period>", "how far back, e.g. 7d, 4w, 3m", "30d")
|
|
444
|
+
.option("--audit-dir <dir>", "read audit files from here instead of the configured directory")
|
|
445
|
+
.action(async (dir, opts) => (await proCommands("usage-report")).usageReport(ctx, dir, opts));
|
|
446
|
+
export { program };
|
|
447
|
+
const isMain = process.argv[1] && /(?:cli\.(?:ts|js)|docsworth)$/.test(process.argv[1]);
|
|
448
|
+
if (isMain) {
|
|
449
|
+
program.parseAsync().catch(async (err) => {
|
|
450
|
+
if (isUserError(err)) {
|
|
451
|
+
// A message can quote a path or a value a page holds: printed escaped.
|
|
452
|
+
console.error(`docsworth: ${printable(err.message)}`);
|
|
453
|
+
// Exit 3 if the failure also left a change its required audit log doesn't show.
|
|
454
|
+
process.exit(process.exitCode === EXIT_AUDIT_DEGRADED ? EXIT_AUDIT_DEGRADED : 2);
|
|
455
|
+
}
|
|
456
|
+
throw err;
|
|
457
|
+
});
|
|
458
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { MirrorPage } from "./mirror-view.js";
|
|
2
|
+
import type { Page, Repo } from "./repo.js";
|
|
3
|
+
/**
|
|
4
|
+
* Who is calling. In GitHub mode this comes from the caller's own token, so GitHub's
|
|
5
|
+
* permissions are the ACL; in local mode (no github.repo) the filesystem is.
|
|
6
|
+
*/
|
|
7
|
+
export interface Identity {
|
|
8
|
+
mode: "github" | "local";
|
|
9
|
+
login: string | null;
|
|
10
|
+
/** Team slugs (lowercase) in the configured org. */
|
|
11
|
+
teams: ReadonlySet<string>;
|
|
12
|
+
repoAccess: boolean;
|
|
13
|
+
/** Push access to the knowledge repo: required to open PRs from a branch. */
|
|
14
|
+
canWrite: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function canSee(page: Pick<Page, "acl">, who: Identity): boolean;
|
|
17
|
+
/** The ONLY way tools obtain pages. Everything downstream works on this filtered list. */
|
|
18
|
+
export declare function visiblePages(repo: Repo, who: Identity): Page[];
|
|
19
|
+
/** The mirrored pages `who` may see: the same rule as pages, on each space's `acl`. */
|
|
20
|
+
export declare function visibleMirror(mirror: readonly MirrorPage[], who: Identity): MirrorPage[];
|
|
21
|
+
export declare const RESTRICTED = "[restricted page]";
|
|
22
|
+
/**
|
|
23
|
+
* Rewrite every link to a page the caller cannot see, so a visible page (say the root
|
|
24
|
+
* index) never reveals a hidden page's title or path: inline links and images, angle
|
|
25
|
+
* destinations, reference-style links (full, collapsed and shortcut) with their
|
|
26
|
+
* definitions, and HTML anchors.
|
|
27
|
+
*/
|
|
28
|
+
export declare function redactLinks(body: string, fromPath: string, repo: Repo, who: Identity): string;
|
|
29
|
+
/** One pass of the sweep for `who` on page `fromPath`: `liberal`, every route naming nothing fails closed, a link or not. */
|
|
30
|
+
export declare function redactionSweep(fromPath: string, repo: Repo, who: Identity, gone?: Set<string>): (text: string, liberal: boolean) => string;
|
|
31
|
+
/**
|
|
32
|
+
* `sweep` run until it changes nothing. Replacing a line can change what the rest means (a
|
|
33
|
+
* fence gone, code is markdown, and its links are links), so a pass can leave work for the
|
|
34
|
+
* next. Past `passes`, one last `liberal` pass, where every route naming nothing fails
|
|
35
|
+
* closed, ends it.
|
|
36
|
+
*/
|
|
37
|
+
export declare function untilStable(text: string, sweep: (text: string, liberal: boolean) => string, passes?: number): string;
|
package/dist/core/acl.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { ACL_INTERNAL } from "./schema.js";
|
|
2
|
+
import { definitionsIn, destinationHides, linkPaths, normalLabel, namingLines, pageNames, replaceLines } from "./references.js";
|
|
3
|
+
export function canSee(page, who) {
|
|
4
|
+
if (!who.repoAccess)
|
|
5
|
+
return false;
|
|
6
|
+
const acl = page.acl.toLowerCase();
|
|
7
|
+
return acl === ACL_INTERNAL || who.teams.has(acl);
|
|
8
|
+
}
|
|
9
|
+
/** The ONLY way tools obtain pages. Everything downstream works on this filtered list. */
|
|
10
|
+
export function visiblePages(repo, who) {
|
|
11
|
+
return repo.pages.filter((p) => canSee(p, who));
|
|
12
|
+
}
|
|
13
|
+
/** The mirrored pages `who` may see: the same rule as pages, on each space's `acl`. */
|
|
14
|
+
export function visibleMirror(mirror, who) {
|
|
15
|
+
return mirror.filter((m) => canSee(m, who));
|
|
16
|
+
}
|
|
17
|
+
export const RESTRICTED = "[restricted page]";
|
|
18
|
+
// Every span is bounded, and link text stops at a `[` (nested brackets are the sweep's):
|
|
19
|
+
// otherwise `[a` repeated (no `]`) made each `[` scan on, 1 s for 64 KB unbounded and
|
|
20
|
+
// still 270 ms on CI bounded. Longer link text or titles are left to the sweep after.
|
|
21
|
+
const TITLE = String.raw `(?:\s+(?:"[^"]{0,999}"|'[^']{0,999}'|\([^)]{0,999}\)))?`;
|
|
22
|
+
// The destination is matched once, in a lookahead, and never given back character by
|
|
23
|
+
// character: `[a](` repeated made each one retry 2,000 lengths. And it stops where it
|
|
24
|
+
// can't go on: an angle one at `<` (CommonMark), a plain one at `[` (rare in a real
|
|
25
|
+
// destination; the sweep after still reads one).
|
|
26
|
+
const INLINE = new RegExp(String.raw `!?\[([^\][]{0,999})\]\(\s*(?=(<[^<>\n]{0,999}>|[^)\s[]{1,2000}))\2${TITLE}\s*\)`, "g");
|
|
27
|
+
const REF_DEF = new RegExp(String.raw `^ {0,3}\[([^\][\n]{1,999})\]:[ \t]*(?=(<[^<>\n]{0,999}>|\S{1,2000}))\2${TITLE}[ \t]*$`, "gm");
|
|
28
|
+
// An anchor's opening tag, and its quoted href. Read in two steps, linear: one regex for
|
|
29
|
+
// the whole anchor backtracked between its parts (16 KB of `<a href="x">` took 1.7 s).
|
|
30
|
+
const A_OPEN = /<a\b[^<>]{0,999}>/gi;
|
|
31
|
+
// On one line: a value broken across lines (behind quote markers, say) is the sweep's.
|
|
32
|
+
const A_HREF = /\bhref\s*=\s*(?:"([^"\n]*)"|'([^'\n]*)')/i;
|
|
33
|
+
const A_CLOSE = /<\/a\s*>/i;
|
|
34
|
+
/**
|
|
35
|
+
* Is a link destination a page the caller may not see? Read as the URL it becomes:
|
|
36
|
+
* escapes, character references and percent-encoding decoded, `\\` as `/`, query and
|
|
37
|
+
* fragment cut, relative or root-relative. A URL counts when it ends with a hidden page's
|
|
38
|
+
* path. Anything under the knowledge root that is not a loadable page fails closed: an
|
|
39
|
+
* invalid page has no trustworthy ACL.
|
|
40
|
+
*/
|
|
41
|
+
function hidesPage(dest, fromPath, repo, who, html = false, links) {
|
|
42
|
+
return destinationHides(dest, fromPath, namesFor(repo, who), html, links);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Rewrite every link to a page the caller cannot see, so a visible page (say the root
|
|
46
|
+
* index) never reveals a hidden page's title or path: inline links and images, angle
|
|
47
|
+
* destinations, reference-style links (full, collapsed and shortcut) with their
|
|
48
|
+
* definitions, and HTML anchors.
|
|
49
|
+
*/
|
|
50
|
+
export function redactLinks(body, fromPath, repo, who) {
|
|
51
|
+
// A route naming nothing fails closed where CommonMark reads a link (references.ts).
|
|
52
|
+
const links = linkPaths(body);
|
|
53
|
+
const hidden = (dest) => hidesPage(dest, fromPath, repo, who, false, links);
|
|
54
|
+
// Reference definitions first: remember which labels point at hidden pages.
|
|
55
|
+
const hiddenLabels = new Set();
|
|
56
|
+
// Only the definition CommonMark puts in effect for a label (code review, round 9): a
|
|
57
|
+
// `]:` inside a paragraph defines nothing, and is left for the sweep.
|
|
58
|
+
const defs = definitionsIn(body);
|
|
59
|
+
let out = body.replace(REF_DEF, (whole, label, dest) => {
|
|
60
|
+
if (!hidden(dest))
|
|
61
|
+
return whole;
|
|
62
|
+
const inEffect = defs?.get(normalLabel(label));
|
|
63
|
+
if (defs && (inEffect === undefined || !hidden(inEffect)))
|
|
64
|
+
return whole;
|
|
65
|
+
hiddenLabels.add(label.trim().toLowerCase());
|
|
66
|
+
return "";
|
|
67
|
+
});
|
|
68
|
+
if (hiddenLabels.size) {
|
|
69
|
+
// [text][label], [label][] and the shortcut form [label].
|
|
70
|
+
out = out.replace(/\[([^\][]{0,999})\]\[([^\][]{0,999})\]/g, (whole, text, label) => hiddenLabels.has((label || text).trim().toLowerCase()) ? RESTRICTED : whole);
|
|
71
|
+
// Not a label right after a `]`: that's a full reference whose text the form above
|
|
72
|
+
// couldn't read (it holds brackets: merge gate, PR 53, round 3), and the label alone
|
|
73
|
+
// would leave its text. The sweep takes those lines whole.
|
|
74
|
+
out = out.replace(/(?<!\])\[([^\][]{1,999})\](?![(\[:])/g, (whole, label) => (hiddenLabels.has(label.trim().toLowerCase()) ? RESTRICTED : whole));
|
|
75
|
+
}
|
|
76
|
+
out = out.replace(INLINE, (whole, _text, dest) => (hidden(dest) ? RESTRICTED : whole));
|
|
77
|
+
out = redactAnchors(out, (href) => hidesPage(href, fromPath, repo, who, true));
|
|
78
|
+
// The passes above keep the rest of a line, but read only the common forms. The sweep
|
|
79
|
+
// after them fails closed: a line that still names a hidden page, in any form a reader
|
|
80
|
+
// resolves, goes whole (references.ts).
|
|
81
|
+
// The labels the first pass took the definitions of: the sweep takes their references,
|
|
82
|
+
// which the forms above miss (`[ref]:` that can't start a definition is one).
|
|
83
|
+
return untilStable(out, redactionSweep(fromPath, repo, who, new Set(hiddenLabels)));
|
|
84
|
+
}
|
|
85
|
+
/** One pass of the sweep for `who` on page `fromPath`: `liberal`, every route naming nothing fails closed, a link or not. */
|
|
86
|
+
export function redactionSweep(fromPath, repo, who, gone = new Set()) {
|
|
87
|
+
const names = namesFor(repo, who);
|
|
88
|
+
// `gone`: the labels the first pass took the definitions of, whose references each pass
|
|
89
|
+
// takes too.
|
|
90
|
+
return (text, liberal) => replaceLines(text, namingLines(text, fromPath, names, 0, [], liberal, gone), RESTRICTED);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* `sweep` run until it changes nothing. Replacing a line can change what the rest means (a
|
|
94
|
+
* fence gone, code is markdown, and its links are links), so a pass can leave work for the
|
|
95
|
+
* next. Past `passes`, one last `liberal` pass, where every route naming nothing fails
|
|
96
|
+
* closed, ends it.
|
|
97
|
+
*/
|
|
98
|
+
export function untilStable(text, sweep, passes = 3) {
|
|
99
|
+
for (let pass = 1;; pass++) {
|
|
100
|
+
const next = sweep(text, pass > passes);
|
|
101
|
+
if (next === text || pass > passes)
|
|
102
|
+
return next;
|
|
103
|
+
text = next;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Each `<a href="…">…</a>` whose href is hidden, as the placeholder (its `</a>` within 4,000 characters). */
|
|
107
|
+
function redactAnchors(text, hidden) {
|
|
108
|
+
let out = "";
|
|
109
|
+
let at = 0;
|
|
110
|
+
for (const m of text.matchAll(A_OPEN)) {
|
|
111
|
+
if (m.index < at)
|
|
112
|
+
continue;
|
|
113
|
+
const href = A_HREF.exec(m[0]);
|
|
114
|
+
if (!href || !hidden(href[1] ?? href[2]))
|
|
115
|
+
continue;
|
|
116
|
+
const after = m.index + m[0].length;
|
|
117
|
+
const close = A_CLOSE.exec(text.slice(after, after + 4000));
|
|
118
|
+
if (!close)
|
|
119
|
+
continue; // the sweep after still reads the tag
|
|
120
|
+
out += text.slice(at, m.index) + RESTRICTED;
|
|
121
|
+
at = after + close.index + close[0].length;
|
|
122
|
+
}
|
|
123
|
+
return out + text.slice(at);
|
|
124
|
+
}
|
|
125
|
+
// Per repo (a reload makes a new one) and per audience: the teams decide what's hidden.
|
|
126
|
+
const NAMES = new WeakMap();
|
|
127
|
+
function namesFor(repo, who) {
|
|
128
|
+
const key = `${who.repoAccess}:${[...who.teams].sort().join(",")}`;
|
|
129
|
+
let byAudience = NAMES.get(repo);
|
|
130
|
+
if (!byAudience)
|
|
131
|
+
NAMES.set(repo, (byAudience = new Map()));
|
|
132
|
+
let names = byAudience.get(key);
|
|
133
|
+
if (!names)
|
|
134
|
+
byAudience.set(key, (names = pageNames(repo, (p) => !canSee(p, who))));
|
|
135
|
+
return names;
|
|
136
|
+
}
|