@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
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { stateDir } from "./audit-log.js";
|
|
5
|
+
import { splitFrontmatter } from "./frontmatter.js";
|
|
6
|
+
import { linesWhere, replaceLines } from "./references.js";
|
|
7
|
+
// Reading the mirror cache (v0.2 L2). The connectors that write it are Docsworth pro's;
|
|
8
|
+
// reading it is core, so the MCP server can serve mirrored pages as
|
|
9
|
+
// context without the proprietary tier. A file counts as mirror output only while a
|
|
10
|
+
// record in this user's state directory (outside the repo, so a checkout can't forge
|
|
11
|
+
// it) says one of their runs wrote it, with exactly this content.
|
|
12
|
+
/** Every source a mirror run writes, one directory each under mirror.out_dir. */
|
|
13
|
+
export const MIRROR_SOURCES = ["confluence", "notion"];
|
|
14
|
+
export const sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
15
|
+
/** The directory's canonical path, so /var/… and /private/var/… (macOS) name one record. */
|
|
16
|
+
export const canonicalDir = (dir) => (existsSync(dir) ? realpathSync(dir) : resolve(dir));
|
|
17
|
+
/** Where the record of a space's mirror output lives, in this user's state directory. */
|
|
18
|
+
export function mirrorRecordPath(repoRoot, spaceDir) {
|
|
19
|
+
return join(stateDir(repoRoot), "mirror", `${sha256(canonicalDir(spaceDir)).slice(0, 24)}.json`);
|
|
20
|
+
}
|
|
21
|
+
/** name → sha256 of what the run wrote, or null when no run of this user's wrote there. */
|
|
22
|
+
export function readOwned(repoRoot, spaceDir) {
|
|
23
|
+
const p = mirrorRecordPath(repoRoot, spaceDir);
|
|
24
|
+
if (!existsSync(p))
|
|
25
|
+
return null;
|
|
26
|
+
try {
|
|
27
|
+
const r = JSON.parse(readFileSync(p, "utf8"));
|
|
28
|
+
if (r.dir !== canonicalDir(spaceDir) || !r.files || typeof r.files !== "object")
|
|
29
|
+
return null;
|
|
30
|
+
return new Map(Object.entries(r.files).filter((e) => typeof e[1] === "string" && /^[a-z0-9_][a-z0-9._-]*$/i.test(e[0]) && !/^\.\.?$/.test(e[0])));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** existsSync follows symlinks; a dangling one still has to be seen. */
|
|
37
|
+
export function lstatExists(p) {
|
|
38
|
+
try {
|
|
39
|
+
lstatSync(p);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The files in a space that one of this user's mirror runs wrote and that still hold
|
|
48
|
+
* exactly what it wrote. The record lives in the user's state directory (ownership.ts),
|
|
49
|
+
* so nothing a checkout contains can make a file count as mirror output.
|
|
50
|
+
*/
|
|
51
|
+
export function ownedFiles(repoRoot, spaceDir) {
|
|
52
|
+
const owned = readOwned(repoRoot, spaceDir);
|
|
53
|
+
if (!owned)
|
|
54
|
+
return [];
|
|
55
|
+
return [...owned]
|
|
56
|
+
.filter(([name, hash]) => {
|
|
57
|
+
const p = join(spaceDir, name);
|
|
58
|
+
if (!lstatExists(p))
|
|
59
|
+
return false;
|
|
60
|
+
const st = lstatSync(p);
|
|
61
|
+
return st.isFile() && sha256(readFileSync(p)) === hash;
|
|
62
|
+
})
|
|
63
|
+
.map(([name]) => name);
|
|
64
|
+
}
|
|
65
|
+
/** The owned files of every space under out_dir (see ownedFiles). */
|
|
66
|
+
/** Every space directory under out_dir: real directories only, never through a symlink. */
|
|
67
|
+
function spaceDirs(outDir) {
|
|
68
|
+
if (!lstatExists(outDir) || lstatSync(outDir).isSymbolicLink())
|
|
69
|
+
return [];
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const source of MIRROR_SOURCES) {
|
|
72
|
+
const sdir = join(outDir, source);
|
|
73
|
+
if (!lstatExists(sdir) || lstatSync(sdir).isSymbolicLink() || !lstatSync(sdir).isDirectory())
|
|
74
|
+
continue;
|
|
75
|
+
for (const space of readdirSync(sdir)) {
|
|
76
|
+
const dir = join(sdir, space);
|
|
77
|
+
const st = lstatSync(dir);
|
|
78
|
+
if (!st.isSymbolicLink() && st.isDirectory())
|
|
79
|
+
out.push(dir);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
export function ownedSpaceFiles(outDir, repoRoot) {
|
|
85
|
+
return spaceDirs(outDir).map((dir) => ({ dir, files: ownedFiles(repoRoot, dir) }));
|
|
86
|
+
}
|
|
87
|
+
/** Every mirrored page this user's runs wrote and that's unchanged since (never a planted file). */
|
|
88
|
+
/** Where each loaded page came from, and what that file held when it was read. */
|
|
89
|
+
const SOURCES = new WeakMap();
|
|
90
|
+
export function loadMirror(outDir, repoRoot) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const dir of spaceDirs(outDir)) {
|
|
93
|
+
const owned = readOwned(repoRoot, dir);
|
|
94
|
+
if (!owned)
|
|
95
|
+
continue;
|
|
96
|
+
for (const [f, hash] of owned) {
|
|
97
|
+
if (!f.endsWith(".md"))
|
|
98
|
+
continue;
|
|
99
|
+
// Read once, and check that same content: checking one read and parsing another
|
|
100
|
+
// would let a file swapped in between pass as owned.
|
|
101
|
+
const file = join(dir, f);
|
|
102
|
+
let raw;
|
|
103
|
+
try {
|
|
104
|
+
if (!lstatSync(file).isFile())
|
|
105
|
+
continue;
|
|
106
|
+
raw = readFileSync(file);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (sha256(raw) !== hash)
|
|
112
|
+
continue;
|
|
113
|
+
const doc = splitFrontmatter(raw.toString("utf8"));
|
|
114
|
+
const meta = doc.data?.mirror;
|
|
115
|
+
if (!meta?.id)
|
|
116
|
+
continue;
|
|
117
|
+
const page = { ...meta, markdown: doc.body.trim() };
|
|
118
|
+
SOURCES.set(page, { file, sha: hash });
|
|
119
|
+
out.push(page);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out.sort((a, b) => a.id.localeCompare(b.id));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Whether a loaded page's file still holds what it held when loaded: a server keeps
|
|
126
|
+
* pages in memory, and an owned file edited or deleted since must stop being served.
|
|
127
|
+
*/
|
|
128
|
+
export function stillOwned(page) {
|
|
129
|
+
const src = SOURCES.get(page);
|
|
130
|
+
if (!src)
|
|
131
|
+
return false;
|
|
132
|
+
try {
|
|
133
|
+
return lstatSync(src.file).isFile() && sha256(readFileSync(src.file)) === src.sha;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The mirror the MCP server serves (`mirror.serve: true`): mirror.out_dir, which must
|
|
141
|
+
* resolve inside the repo, read through the ownership records like every other reader.
|
|
142
|
+
* Anything wrong yields an empty mirror and a warning, never a failed search.
|
|
143
|
+
*/
|
|
144
|
+
export function servedMirror(repoRoot, outDir, warn = (m) => process.stderr.write(`docsworth: ${m}\n`)) {
|
|
145
|
+
const root = canonicalDir(repoRoot);
|
|
146
|
+
const dir = resolve(repoRoot, outDir);
|
|
147
|
+
const real = canonicalDir(dir);
|
|
148
|
+
const rel = relative(root, real);
|
|
149
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
|
|
150
|
+
warn(`mirror.out_dir ${outDir} resolves outside the repository; serving no mirrored pages`);
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
return loadMirror(dir, repoRoot);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
warn(`couldn't read the mirror at ${outDir} (${err.message}); serving no mirrored pages`);
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const normUrl = (u) => u.trim().replace(/^<|>$/g, "").replace(/[?#].*$/, "").replace(/\/+$/, "").toLowerCase();
|
|
162
|
+
const BARE_URL = /<?\bhttps?:\/\/[^\s<>\]"'`)]+>?/gi;
|
|
163
|
+
// A reference definition, its destination possibly on the next line, any title after.
|
|
164
|
+
// Quote markers then the label, with no two adjacent runs that could share the same
|
|
165
|
+
// spaces: `[ \t]*(?:>[ \t]?)*[ \t]*` backtracked between them (64 KB of spaces and
|
|
166
|
+
// tabs took 2.3 s).
|
|
167
|
+
const REF_DEF_LINE = /^[ \t]*(?:>[ \t]*)*\[((?:\\.|[^\[\]\\\n])+)\]:[ \t]*(?:\n[ \t>]*)?(<[^>\n]*>|\S+)[^\n]*(?:\n[ \t>]+["'(][^\n]*)?$/gm;
|
|
168
|
+
/** For every `]`, its matching `[` (escapes honoured, reset at a blank line). */
|
|
169
|
+
function bracketMatches(text) {
|
|
170
|
+
const match = new Map();
|
|
171
|
+
const open = [];
|
|
172
|
+
for (let k = 0; k < text.length; k++) {
|
|
173
|
+
const c = text[k];
|
|
174
|
+
if (c === "\\")
|
|
175
|
+
k++;
|
|
176
|
+
else if (c === "[")
|
|
177
|
+
open.push(k);
|
|
178
|
+
else if (c === "]") {
|
|
179
|
+
const start = open.pop();
|
|
180
|
+
if (start !== undefined)
|
|
181
|
+
match.set(k, start);
|
|
182
|
+
}
|
|
183
|
+
else if (c === "\n" && text[k + 1] === "\n")
|
|
184
|
+
open.length = 0;
|
|
185
|
+
}
|
|
186
|
+
return match;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Every markdown link (or image) whose destination `hidden` accepts, as a span from its
|
|
190
|
+
* `[` (or `!`) to its closing `)`: found from each `](`, back to the matching bracket,
|
|
191
|
+
* so link text with escaped or nested brackets is covered too.
|
|
192
|
+
*/
|
|
193
|
+
const DEST_REACH = 512;
|
|
194
|
+
function hiddenLinkSpans(text, hidden) {
|
|
195
|
+
const match = bracketMatches(text);
|
|
196
|
+
const out = [];
|
|
197
|
+
for (let i = text.indexOf("]("); i >= 0; i = text.indexOf("](", i + 1)) {
|
|
198
|
+
let j = i + 2;
|
|
199
|
+
while (/[ \t]/.test(text[j] ?? ""))
|
|
200
|
+
j++;
|
|
201
|
+
let dest;
|
|
202
|
+
// Unbounded, `(]` repeated made each `](` scan to the end of the text (9 s for 64 KB);
|
|
203
|
+
// a destination now ends at a bracket too, and an angle one at `<`, so no scan passes
|
|
204
|
+
// the next `](`. A title is read at most DEST_REACH characters.
|
|
205
|
+
if (text[j] === "<") {
|
|
206
|
+
// An angle destination can't hold `<` (CommonMark), so the scan stops at one.
|
|
207
|
+
const m = /^<([^<>\n]*)>/.exec(text.slice(j, j + DEST_REACH));
|
|
208
|
+
if (!m)
|
|
209
|
+
continue;
|
|
210
|
+
dest = m[1];
|
|
211
|
+
j += m[0].length;
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
const from = j;
|
|
215
|
+
let depth = 0;
|
|
216
|
+
for (; j < text.length; j++) {
|
|
217
|
+
const c = text.charCodeAt(j);
|
|
218
|
+
// Whitespace ends a destination; so does a bracket here (rare in a real one, and
|
|
219
|
+
// the id sweep after still reads it), so `(]` repeated can't make each scan long.
|
|
220
|
+
if (c === 32 || (c >= 9 && c <= 13) || c === 91 || c === 93)
|
|
221
|
+
break;
|
|
222
|
+
if (c === 92)
|
|
223
|
+
j++; // `\`
|
|
224
|
+
else if (c === 40)
|
|
225
|
+
depth++; // `(`
|
|
226
|
+
else if (c === 41 && depth-- === 0)
|
|
227
|
+
break; // `)`
|
|
228
|
+
}
|
|
229
|
+
dest = text.slice(from, j);
|
|
230
|
+
}
|
|
231
|
+
if (!hidden(dest))
|
|
232
|
+
continue;
|
|
233
|
+
// Past a title ("t", 't' or (t), escapes honoured) to the link's `)`; without one,
|
|
234
|
+
// the destination's end, so nothing after the link is swallowed.
|
|
235
|
+
let k = j;
|
|
236
|
+
while (/[ \t\n]/.test(text[k] ?? ""))
|
|
237
|
+
k++;
|
|
238
|
+
const opener = text[k];
|
|
239
|
+
if (k > j && (opener === '"' || opener === "'" || opener === "(")) {
|
|
240
|
+
const closer = opener === "(" ? ")" : opener;
|
|
241
|
+
const limit = k + DEST_REACH;
|
|
242
|
+
for (k++; k < text.length && k < limit && text[k] !== closer; k++)
|
|
243
|
+
if (text[k] === "\\")
|
|
244
|
+
k++;
|
|
245
|
+
k++;
|
|
246
|
+
while (/[ \t\n]/.test(text[k] ?? ""))
|
|
247
|
+
k++;
|
|
248
|
+
}
|
|
249
|
+
const end = text[k] === ")" ? k + 1 : text[j] === ")" ? j + 1 : j;
|
|
250
|
+
let start = match.get(i) ?? i;
|
|
251
|
+
if (text[start - 1] === "!")
|
|
252
|
+
start--;
|
|
253
|
+
out.push([start, end]);
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* A mirrored page's text with every link to a mirrored page the caller can't see
|
|
259
|
+
* replaced by `placeholder`: markdown links and bare URLs pointing at it (compared
|
|
260
|
+
* case-insensitively, without query or fragment), and the Confluence form (the
|
|
261
|
+
* converter writes a page link as its title in emphasis; `links_to` names it by title
|
|
262
|
+
* or native id).
|
|
263
|
+
*/
|
|
264
|
+
/**
|
|
265
|
+
* The page a link names, whatever form it takes: a Confluence page by id (absolute,
|
|
266
|
+
* relative, with or without its title slug, or `viewpage.action?pageId=`), a Notion
|
|
267
|
+
* page by its 32-hex id, and anything else by its normalised URL.
|
|
268
|
+
*/
|
|
269
|
+
function pageKey(u) {
|
|
270
|
+
const confluence = /\/pages\/(\d+)(?:[/?#]|$)|[?&]pageId=(\d+)/i.exec(u);
|
|
271
|
+
if (confluence)
|
|
272
|
+
return `confluence:${confluence[1] ?? confluence[2]}`;
|
|
273
|
+
// A Notion id anywhere: in a notion.so or notion.site URL, a relative link, or alone.
|
|
274
|
+
const notion = /(?:^|[/=-])([0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12})(?:[/?#]|$)/i.exec(u);
|
|
275
|
+
if (notion)
|
|
276
|
+
return `notion:${notion[1].replace(/-/g, "").toLowerCase()}`;
|
|
277
|
+
return normUrl(u);
|
|
278
|
+
}
|
|
279
|
+
const keyOf = (h) => (h.source === "confluence" ? `confluence:${h.native_id}` : `notion:${h.native_id.replace(/-/g, "").toLowerCase()}`);
|
|
280
|
+
// A Confluence page path written out without a scheme: `/wiki/spaces/SEC/pages/900/…`.
|
|
281
|
+
// Or the old `viewpage.action?pageId=` form. And a Notion id written bare.
|
|
282
|
+
const RELATIVE_PAGE = /(?<![\w/])\/(?:wiki\/)?(?:spaces\/[^\s/)\]]+\/pages\/\d+|pages\/viewpage\.action\?pageId=\d+)[^\s)\]<>"'`]*/gi;
|
|
283
|
+
const BARE_NOTION_ID = /(?<![\w-])[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}(?![\w-])/gi;
|
|
284
|
+
/**
|
|
285
|
+
* `text` with every link to one of `hidden` (mirrored pages the reader can't see)
|
|
286
|
+
* replaced by `placeholder`. For a repo page as much as a mirrored one: a visible page
|
|
287
|
+
* never shows a hidden one's link. `linksTo` adds the Confluence linked-title form.
|
|
288
|
+
*/
|
|
289
|
+
export function redactHiddenLinks(text, hidden, placeholder, linksTo = []) {
|
|
290
|
+
if (!hidden.length)
|
|
291
|
+
return text;
|
|
292
|
+
const keys = new Set([...hidden.map(keyOf), ...hidden.map((h) => pageKey(h.url))]);
|
|
293
|
+
const isHidden = (u) => keys.has(pageKey(u.replace(/[.,;:!?]+$/, "")));
|
|
294
|
+
// Reference-style links first: a definition pointing at a hidden page goes (title and
|
|
295
|
+
// all), and so does every use of its label (`[text][label]`, `[label][]`, `[label]`).
|
|
296
|
+
const labels = new Set();
|
|
297
|
+
let source = text.replace(REF_DEF_LINE, (whole, label, dest) => {
|
|
298
|
+
if (!isHidden(dest.replace(/^<|>$/g, "")))
|
|
299
|
+
return whole;
|
|
300
|
+
labels.add(label.trim().replace(/\s+/g, " ").toLowerCase());
|
|
301
|
+
return "";
|
|
302
|
+
});
|
|
303
|
+
if (labels.size) {
|
|
304
|
+
const named = (l) => labels.has(l.trim().replace(/\s+/g, " ").toLowerCase());
|
|
305
|
+
source = source.replace(/\[((?:\\.|[^\[\]\\]|\[(?:\\.|[^\[\]\\])*\])*)\]\[((?:\\.|[^\[\]\\])*)\]/g, (whole, shown, l) => (named(l || shown) ? placeholder : whole));
|
|
306
|
+
source = source.replace(/\[((?:\\.|[^\[\]\\])+)\](?![(\[:])/g, (whole, l) => (named(l) ? placeholder : whole));
|
|
307
|
+
}
|
|
308
|
+
let out = "";
|
|
309
|
+
let at = 0;
|
|
310
|
+
for (const [i, j] of hiddenLinkSpans(source, isHidden)) {
|
|
311
|
+
if (i < at)
|
|
312
|
+
continue;
|
|
313
|
+
out += source.slice(at, i) + placeholder;
|
|
314
|
+
at = j;
|
|
315
|
+
}
|
|
316
|
+
out += source.slice(at);
|
|
317
|
+
const bare = (u) => {
|
|
318
|
+
const tail = /[.,;:!?]*$/.exec(u)[0]; // sentence punctuation stays
|
|
319
|
+
return isHidden(u) ? placeholder + tail : u;
|
|
320
|
+
};
|
|
321
|
+
out = out.replace(BARE_URL, bare).replace(RELATIVE_PAGE, bare).replace(BARE_NOTION_ID, bare);
|
|
322
|
+
const linked = new Set(linksTo);
|
|
323
|
+
for (const h of hidden)
|
|
324
|
+
if (linked.has(h.title) || linked.has(h.native_id))
|
|
325
|
+
for (const mark of ["_", "*"])
|
|
326
|
+
out = out.split(`${mark}${h.title}${mark}`).join(placeholder);
|
|
327
|
+
// Then fail closed, as for repo pages: a line that still names a hidden page's id, in
|
|
328
|
+
// any form a reader decodes (`/`, `%2F`, `\`, a value broken across lines) or a
|
|
329
|
+
// renderer shows (`/pages/**900**`), goes whole (references.ts).
|
|
330
|
+
const confluence = new Set(hidden.filter((h) => h.source === "confluence").map((h) => h.native_id));
|
|
331
|
+
const notion = new Set(hidden.filter((h) => h.source === "notion").map((h) => keyOf(h).slice("notion:".length)));
|
|
332
|
+
return replaceLines(out, linesWhere(out, (view, cased) => idSpans(view, cased, confluence, notion)), placeholder);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* A Confluence page's tiny link id (`/wiki/x/<id>`, what "Copy link" gives): the page id
|
|
336
|
+
* as little-endian bytes (four at least), base64 with `-` and `_`, padding and trailing
|
|
337
|
+
* `A`s dropped. Checked against 25 live `_links.tinyui` values.
|
|
338
|
+
*/
|
|
339
|
+
export function tinyId(pageId) {
|
|
340
|
+
let n = BigInt(pageId);
|
|
341
|
+
const bytes = [];
|
|
342
|
+
for (; n > 0n; n >>= 8n)
|
|
343
|
+
bytes.push(Number(n & 0xffn));
|
|
344
|
+
while (bytes.length < 4)
|
|
345
|
+
bytes.push(0);
|
|
346
|
+
return Buffer.from(bytes).toString("base64").replace(/\//g, "-").replace(/\+/g, "_").replace(/=+$/, "").replace(/A+$/, "");
|
|
347
|
+
}
|
|
348
|
+
/** Where a view (decoded; lower case, and `cased` as written) names one of these pages: by id, or a tiny link. */
|
|
349
|
+
function* idSpans(view, cased, confluence, notion) {
|
|
350
|
+
if (confluence.size)
|
|
351
|
+
for (const m of view.matchAll(/(?:\/pages\/|pageid=)(\d+)(?!\d)/g))
|
|
352
|
+
if (confluence.has(m[1]))
|
|
353
|
+
yield [m.index, m.index + m[0].length];
|
|
354
|
+
// A tiny id is base64, so its case counts (`aQ` is page 105, `AQ` page 1): read as written.
|
|
355
|
+
const tiny = new Set([...confluence].filter((id) => /^\d+$/.test(id)).map(tinyId));
|
|
356
|
+
if (tiny.size)
|
|
357
|
+
for (const m of cased.matchAll(/\/[xX]\/([A-Za-z0-9_-]+)/g))
|
|
358
|
+
if (tiny.has(m[1]))
|
|
359
|
+
yield [m.index, m.index + m[0].length];
|
|
360
|
+
if (notion.size)
|
|
361
|
+
for (const m of view.matchAll(/(?<![0-9a-f])[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}(?![0-9a-f])/g))
|
|
362
|
+
if (notion.has(m[0].replace(/-/g, "")))
|
|
363
|
+
yield [m.index, m.index + m[0].length];
|
|
364
|
+
}
|
|
365
|
+
export function redactMirror(page, hidden, placeholder) {
|
|
366
|
+
return redactHiddenLinks(page.markdown, hidden, placeholder, page.links_to);
|
|
367
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type Repo } from "./repo.js";
|
|
2
|
+
import { type PageType } from "./schema.js";
|
|
3
|
+
export type NewPageType = Exclude<PageType, "adr">;
|
|
4
|
+
/** Id prefixes by type, as the example repo uses them. */
|
|
5
|
+
export declare const NEW_PAGE_PREFIX: Readonly<Record<NewPageType, string>>;
|
|
6
|
+
/** Where each type goes under the knowledge root, unless a directory is given. */
|
|
7
|
+
export declare const NEW_PAGE_DIR: Readonly<Record<NewPageType, string>>;
|
|
8
|
+
export declare function slugify(s: string): string;
|
|
9
|
+
export interface NewPageInput {
|
|
10
|
+
type: PageType;
|
|
11
|
+
title: string;
|
|
12
|
+
id: string;
|
|
13
|
+
/** The file name's stem: `<dir>/<slug>.md`, or `<dir>/<slug>/index.md` for a service in its default directory. */
|
|
14
|
+
slug: string;
|
|
15
|
+
ownerTeam: string;
|
|
16
|
+
status: "canonical" | "draft";
|
|
17
|
+
/** A directory under the knowledge root; the type's default when absent. */
|
|
18
|
+
dir?: string;
|
|
19
|
+
acl?: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
reviewEvery?: string;
|
|
22
|
+
body: string;
|
|
23
|
+
now: Date;
|
|
24
|
+
}
|
|
25
|
+
export type NewPageProblem = {
|
|
26
|
+
kind: "adr";
|
|
27
|
+
} | {
|
|
28
|
+
kind: "bad_dir";
|
|
29
|
+
} | {
|
|
30
|
+
kind: "id_taken";
|
|
31
|
+
path: string;
|
|
32
|
+
} | {
|
|
33
|
+
kind: "path_taken";
|
|
34
|
+
path: string;
|
|
35
|
+
} | {
|
|
36
|
+
kind: "owner_mismatch";
|
|
37
|
+
path: string;
|
|
38
|
+
assigned: string | null;
|
|
39
|
+
} | {
|
|
40
|
+
kind: "invalid";
|
|
41
|
+
issues: string[];
|
|
42
|
+
};
|
|
43
|
+
export type NewPage = {
|
|
44
|
+
ok: true;
|
|
45
|
+
path: string;
|
|
46
|
+
id: string;
|
|
47
|
+
content: string;
|
|
48
|
+
} | {
|
|
49
|
+
ok: false;
|
|
50
|
+
problem: NewPageProblem;
|
|
51
|
+
};
|
|
52
|
+
export declare function buildNewPage(repo: Repo, input: NewPageInput): NewPage;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { stringify } from "yaml";
|
|
2
|
+
import { resolveTeamOwner } from "./codeowners.js";
|
|
3
|
+
import { formatDate, utcToday } from "./duration.js";
|
|
4
|
+
import { isPage, parsePage } from "./repo.js";
|
|
5
|
+
import { ACL_INTERNAL } from "./schema.js";
|
|
6
|
+
/** Id prefixes by type, as the example repo uses them. */
|
|
7
|
+
export const NEW_PAGE_PREFIX = { runbook: "rb", service: "svc", guide: "guide", policy: "pol" };
|
|
8
|
+
/** Where each type goes under the knowledge root, unless a directory is given. */
|
|
9
|
+
export const NEW_PAGE_DIR = { runbook: "runbooks", service: "services", guide: "guides", policy: "policies" };
|
|
10
|
+
export function slugify(s) {
|
|
11
|
+
return s.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "page";
|
|
12
|
+
}
|
|
13
|
+
export function buildNewPage(repo, input) {
|
|
14
|
+
const fail = (problem) => ({ ok: false, problem });
|
|
15
|
+
if (input.type === "adr")
|
|
16
|
+
return fail({ kind: "adr" });
|
|
17
|
+
const type = input.type;
|
|
18
|
+
const taken = repo.byId.get(input.id);
|
|
19
|
+
if (taken)
|
|
20
|
+
return fail({ kind: "id_taken", path: taken.path });
|
|
21
|
+
const root = repo.config.knowledge_root;
|
|
22
|
+
const dir = (input.dir ?? NEW_PAGE_DIR[type]).replace(/\/+$/g, "");
|
|
23
|
+
// The target must stay under the knowledge root: no absolute paths, backslashes or dot segments.
|
|
24
|
+
if (!dir || dir.startsWith("/") || dir.includes("\\") || dir.split("/").some((seg) => seg === "" || seg === "." || seg === ".."))
|
|
25
|
+
return fail({ kind: "bad_dir" });
|
|
26
|
+
const path = type === "service" && input.dir === undefined ? `${root}/${dir}/${input.slug}/index.md` : `${root}/${dir}/${input.slug}.md`;
|
|
27
|
+
if (repo.byPath.has(path))
|
|
28
|
+
return fail({ kind: "path_taken", path });
|
|
29
|
+
// The page must pass lint where it lands: CODEOWNERS has to assign this team there.
|
|
30
|
+
const own = resolveTeamOwner(repo.codeowners, path, repo.config.github.org);
|
|
31
|
+
if (!own.ok || own.team !== input.ownerTeam)
|
|
32
|
+
return fail({ kind: "owner_mismatch", path, assigned: own.ok ? own.team : null });
|
|
33
|
+
const fm = {
|
|
34
|
+
title: input.title,
|
|
35
|
+
id: input.id,
|
|
36
|
+
type,
|
|
37
|
+
status: input.status,
|
|
38
|
+
owner_team: input.ownerTeam,
|
|
39
|
+
last_verified: formatDate(utcToday(input.now)),
|
|
40
|
+
review_every: input.reviewEvery ?? "90d",
|
|
41
|
+
...(input.acl && input.acl !== ACL_INTERNAL ? { acl: input.acl } : {}),
|
|
42
|
+
...(input.description !== undefined ? { description: input.description } : {}),
|
|
43
|
+
};
|
|
44
|
+
const content = `---\n${stringify(fm)}---\n${input.body}`;
|
|
45
|
+
const parsed = parsePage(path, content);
|
|
46
|
+
if (!isPage(parsed))
|
|
47
|
+
return fail({ kind: "invalid", issues: parsed.issues.map((i) => i.message) });
|
|
48
|
+
return { ok: true, path, id: input.id, content };
|
|
49
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Identity } from "./acl.js";
|
|
2
|
+
import type { Config } from "./config.js";
|
|
3
|
+
export type Policy = Config["policy"];
|
|
4
|
+
/**
|
|
5
|
+
* Why `who` may not call `tool` under `policy`, or null when they may. A tool answers to
|
|
6
|
+
* its registry entry's `policy` name (tool-registry.ts); a name that is no tool is refused.
|
|
7
|
+
*/
|
|
8
|
+
export declare function toolRefusal(policy: Policy, org: string, tool: string, who: Identity): string | null;
|
|
9
|
+
/** A short, content-free summary for the audit log's server_start event. */
|
|
10
|
+
export declare function policySummary(policy: Policy): Record<string, unknown>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { toolSpec } from "./tool-registry.js";
|
|
2
|
+
/**
|
|
3
|
+
* Why `who` may not call `tool` under `policy`, or null when they may. A tool answers to
|
|
4
|
+
* its registry entry's `policy` name (tool-registry.ts); a name that is no tool is refused.
|
|
5
|
+
*/
|
|
6
|
+
export function toolRefusal(policy, org, tool, who) {
|
|
7
|
+
const spec = toolSpec(tool);
|
|
8
|
+
if (!spec)
|
|
9
|
+
return `no tool called \`${tool}\``;
|
|
10
|
+
if (spec.write && policy.writes === "disabled")
|
|
11
|
+
return "writes are disabled by policy (policy.writes in .knowledge/config.yaml)";
|
|
12
|
+
const rule = policy.tools[spec.policy];
|
|
13
|
+
if (!rule)
|
|
14
|
+
return null;
|
|
15
|
+
if (rule.teams.some((t) => who.teams.has(t)))
|
|
16
|
+
return null;
|
|
17
|
+
const what = spec.policy === tool ? `\`${tool}\`` : `\`${spec.policy}\` (which \`${tool}\` answers to)`;
|
|
18
|
+
return rule.teams.length ? `policy allows ${what} only for ${rule.teams.map((t) => `@${org}/${t}`).join(", ")}` : `policy disables ${what} for everyone`;
|
|
19
|
+
}
|
|
20
|
+
/** A short, content-free summary for the audit log's server_start event. */
|
|
21
|
+
export function policySummary(policy) {
|
|
22
|
+
return {
|
|
23
|
+
admins: policy.admins ?? null,
|
|
24
|
+
writes: policy.writes,
|
|
25
|
+
restricted_tools: Object.keys(policy.tools).sort(),
|
|
26
|
+
require_human_approval: policy.require_human_approval,
|
|
27
|
+
include_stale: policy.include_stale,
|
|
28
|
+
identity_cache_seconds: policy.identity_cache_seconds,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export declare const NEGATIONS: string[];
|
|
2
|
+
export type Definition = {
|
|
3
|
+
label: string;
|
|
4
|
+
dest: string;
|
|
5
|
+
title: string;
|
|
6
|
+
};
|
|
7
|
+
/** What the guard compares, taken from one version of the page. */
|
|
8
|
+
export declare function facts(text: string): {
|
|
9
|
+
frontmatter: string;
|
|
10
|
+
fences: string[];
|
|
11
|
+
comments: string[];
|
|
12
|
+
tags: string[];
|
|
13
|
+
openers: string[];
|
|
14
|
+
commentHidesCode: boolean;
|
|
15
|
+
unsureDefinitions: boolean;
|
|
16
|
+
linkTab: boolean;
|
|
17
|
+
unusual: string[];
|
|
18
|
+
invisibles: string[];
|
|
19
|
+
entities: string[];
|
|
20
|
+
boxes: string[];
|
|
21
|
+
linkPairs: string[];
|
|
22
|
+
codeOrder: string;
|
|
23
|
+
codeBySection: Map<string, string[]>;
|
|
24
|
+
headingCounts: Map<string, number>;
|
|
25
|
+
digitWords: string[];
|
|
26
|
+
digits: string[];
|
|
27
|
+
digitsSpaced: string[];
|
|
28
|
+
inline: string[];
|
|
29
|
+
links: string[];
|
|
30
|
+
urls: string[];
|
|
31
|
+
struck: string[];
|
|
32
|
+
numbers: string[];
|
|
33
|
+
shown: Map<string, string>;
|
|
34
|
+
negations: string[];
|
|
35
|
+
negationsSpaced: string[];
|
|
36
|
+
};
|
|
37
|
+
/** Larger pages aren't polished: some patterns here cost more than linear time. */
|
|
38
|
+
export declare const POLISH_MAX_CHARS: number;
|
|
39
|
+
/** Every change the guard sees between two versions, in words. Empty means it passes. */
|
|
40
|
+
export declare function polishChanges(before: string, after: string): string[];
|
|
41
|
+
/** What the guard covered, for the PR body. */
|
|
42
|
+
export declare function polishSummary(text: string): string;
|