@docsworth/cli 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +186 -2
  3. package/dist/cli.d.ts +11 -0
  4. package/dist/cli.js +409 -0
  5. package/dist/core/acl.d.ts +28 -0
  6. package/dist/core/acl.js +102 -0
  7. package/dist/core/audit-log.d.ts +133 -0
  8. package/dist/core/audit-log.js +290 -0
  9. package/dist/core/blocks.d.ts +35 -0
  10. package/dist/core/blocks.js +417 -0
  11. package/dist/core/codeowners.d.ts +62 -0
  12. package/dist/core/codeowners.js +133 -0
  13. package/dist/core/config.d.ts +96 -0
  14. package/dist/core/config.js +131 -0
  15. package/dist/core/coverage.d.ts +25 -0
  16. package/dist/core/coverage.js +41 -0
  17. package/dist/core/duration.d.ts +20 -0
  18. package/dist/core/duration.js +60 -0
  19. package/dist/core/errors.d.ts +11 -0
  20. package/dist/core/errors.js +13 -0
  21. package/dist/core/frontmatter.d.ts +17 -0
  22. package/dist/core/frontmatter.js +40 -0
  23. package/dist/core/gate.d.ts +17 -0
  24. package/dist/core/gate.js +25 -0
  25. package/dist/core/github.d.ts +152 -0
  26. package/dist/core/github.js +234 -0
  27. package/dist/core/host.d.ts +125 -0
  28. package/dist/core/host.js +22 -0
  29. package/dist/core/identity.d.ts +39 -0
  30. package/dist/core/identity.js +127 -0
  31. package/dist/core/init.d.ts +25 -0
  32. package/dist/core/init.js +86 -0
  33. package/dist/core/lint.d.ts +17 -0
  34. package/dist/core/lint.js +115 -0
  35. package/dist/core/mcp/audit-transport.d.ts +16 -0
  36. package/dist/core/mcp/audit-transport.js +238 -0
  37. package/dist/core/mcp/server.d.ts +19 -0
  38. package/dist/core/mcp/server.js +207 -0
  39. package/dist/core/mcp/text.d.ts +14 -0
  40. package/dist/core/mcp/text.js +43 -0
  41. package/dist/core/mirror-view.d.ts +68 -0
  42. package/dist/core/mirror-view.js +367 -0
  43. package/dist/core/new-page.d.ts +52 -0
  44. package/dist/core/new-page.js +49 -0
  45. package/dist/core/policy.d.ts +10 -0
  46. package/dist/core/policy.js +30 -0
  47. package/dist/core/polish.d.ts +42 -0
  48. package/dist/core/polish.js +1000 -0
  49. package/dist/core/references.d.ts +52 -0
  50. package/dist/core/references.js +1242 -0
  51. package/dist/core/repo.d.ts +42 -0
  52. package/dist/core/repo.js +116 -0
  53. package/dist/core/schema.d.ts +46 -0
  54. package/dist/core/schema.js +49 -0
  55. package/dist/core/search.d.ts +24 -0
  56. package/dist/core/search.js +98 -0
  57. package/dist/core/serve/auth.d.ts +23 -0
  58. package/dist/core/serve/auth.js +38 -0
  59. package/dist/core/serve/server.d.ts +22 -0
  60. package/dist/core/serve/server.js +138 -0
  61. package/dist/core/serve/sessions.d.ts +39 -0
  62. package/dist/core/serve/sessions.js +130 -0
  63. package/dist/core/serve/token.d.ts +10 -0
  64. package/dist/core/serve/token.js +96 -0
  65. package/dist/core/service.d.ts +375 -0
  66. package/dist/core/service.js +815 -0
  67. package/dist/core/sla.d.ts +16 -0
  68. package/dist/core/sla.js +20 -0
  69. package/dist/core/stale.d.ts +16 -0
  70. package/dist/core/stale.js +23 -0
  71. package/dist/core/tool-registry.d.ts +31 -0
  72. package/dist/core/tool-registry.js +77 -0
  73. package/dist/core/url.d.ts +4 -0
  74. package/dist/core/url.js +31 -0
  75. package/dist/core/version.d.ts +22 -0
  76. package/dist/core/version.js +32 -0
  77. package/dist/core/writes.d.ts +58 -0
  78. package/dist/core/writes.js +113 -0
  79. package/dist/web/assets/index-BglLMIyN.css +1 -0
  80. package/dist/web/assets/index-DugFPoRu.js +25 -0
  81. package/dist/web/index.html +14 -0
  82. package/docs/format-spec.md +311 -0
  83. package/package.json +75 -4
  84. package/plugin/.claude-plugin/plugin.json +8 -0
  85. package/plugin/.mcp.json +8 -0
  86. package/plugin/bin/docsworth-mcp.mjs +46 -0
  87. package/plugin/skills/draft-runbook/SKILL.md +75 -0
  88. package/plugin/skills/polish-page/SKILL.md +64 -0
  89. package/plugin/skills/update-after-incident/SKILL.md +59 -0
  90. package/plugin/skills/verify-page/SKILL.md +46 -0
  91. package/templates/github/knowledge.yml +51 -0
@@ -0,0 +1,417 @@
1
+ /**
2
+ * The block structure the polish guard needs: which lines are code, in what blocks, as
3
+ * CommonMark reads them. It follows the spec's parsing strategy for containers (block
4
+ * quotes and list items, matched line by line, with lazy paragraph continuation) and
5
+ * for the two code leaves (fenced and indented). Other leaves only matter here for
6
+ * whether a paragraph is open: a paragraph can't be interrupted by indented code, and
7
+ * only by a list item that has text and, if ordered, starts at 1.
8
+ *
9
+ * Tabs count to the next multiple of four columns, and a tab a container consumes in
10
+ * part leaves the rest as spaces, as CommonMark specifies.
11
+ *
12
+ * Raw HTML blocks aren't modelled: the guard refuses a page that has raw HTML.
13
+ */
14
+ /** Left in the prose where a code block was. Not a letter, digit or markup. */
15
+ export const FENCE_MARK = "\u0002";
16
+ function cursor(s) {
17
+ let lastText = -1;
18
+ let lastOther = -1;
19
+ for (let k = s.length - 1; k >= 0 && (lastText < 0 || lastOther < 0); k--) {
20
+ if (lastText < 0 && !isWs(s[k]))
21
+ lastText = k;
22
+ if (lastOther < 0 && !/[ \t*_-]/.test(s[k]))
23
+ lastOther = k;
24
+ }
25
+ return { s, i: 0, col: 0, partial: 0, lastText, lastOther };
26
+ }
27
+ /** Where the whitespace ahead ends. */
28
+ const wsEnd = (c) => {
29
+ let j = c.i;
30
+ while (isWs(c.s[j]))
31
+ j++;
32
+ return j;
33
+ };
34
+ const isWs = (ch) => ch === " " || ch === "\t";
35
+ /** Columns of whitespace ahead. */
36
+ function indent(c) {
37
+ let n = c.partial;
38
+ let col = c.col;
39
+ for (let j = c.i; isWs(c.s[j]); j++) {
40
+ const w = c.s[j] === "\t" ? 4 - (col % 4) : 1;
41
+ n += w;
42
+ col += w;
43
+ }
44
+ return n;
45
+ }
46
+ /** The text after the whitespace ahead. */
47
+ const afterWs = (c) => {
48
+ let j = c.i;
49
+ while (isWs(c.s[j]))
50
+ j++;
51
+ return c.s.slice(j);
52
+ };
53
+ /** Consume up to `k` columns of whitespace. */
54
+ function consume(c, k) {
55
+ const take = Math.min(c.partial, k);
56
+ c.partial -= take;
57
+ k -= take;
58
+ while (k > 0 && isWs(c.s[c.i])) {
59
+ const w = c.s[c.i] === "\t" ? 4 - (c.col % 4) : 1;
60
+ c.i++;
61
+ c.col += w;
62
+ if (w > k) {
63
+ c.partial = w - k;
64
+ k = 0;
65
+ }
66
+ else
67
+ k -= w;
68
+ }
69
+ }
70
+ /** Consume one character that isn't whitespace (a marker). */
71
+ function step(c, n = 1) {
72
+ c.i += n;
73
+ c.col += n;
74
+ }
75
+ /** After `>`: one optional space, which may be a column of a tab. */
76
+ function optionalSpace(c) {
77
+ if (c.partial > 0)
78
+ c.partial--;
79
+ else if (c.s[c.i] === " ")
80
+ step(c);
81
+ else if (c.s[c.i] === "\t") {
82
+ const w = 4 - (c.col % 4);
83
+ c.i++;
84
+ c.col += w;
85
+ c.partial = w - 1;
86
+ }
87
+ }
88
+ const rest = (c) => " ".repeat(c.partial) + c.s.slice(c.i);
89
+ const blankRest = (c) => c.lastText < c.i;
90
+ const THEMATIC = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/;
91
+ /** A thematic break from the whitespace ahead to the line's end? Tested only when nothing else is left. */
92
+ const thematicAhead = (c) => c.lastOther < wsEnd(c) && THEMATIC.test(c.s.slice(wsEnd(c)));
93
+ // A list marker where the whitespace ahead ends (sticky: no copy of the rest of the line).
94
+ const LIST_MARKER_AT = /(?:([-*+])|(\d{1,9})([.)]))(?=[ \t]|$)/y;
95
+ const ATX = /^#{1,6}(?:[ \t]|$)/;
96
+ const SETEXT = /^(?:=+|-+)[ \t]*$/;
97
+ const FENCE_OPEN = /^(`{3,}|~{3,})(.*)$/;
98
+ const LIST_MARKER = /^(?:([-*+])|(\d{1,9})([.)]))(?=[ \t]|$)/;
99
+ // HTML blocks (CommonMark §4.6): seven kinds, each with its start and end. Types 1 to 5
100
+ // run to a line holding their end marker, 6 and 7 to a blank line; 7 (a complete tag
101
+ // alone on its line) can't interrupt a paragraph. Lines in one aren't a paragraph's, so
102
+ // no reference definition hides in an HTML comment.
103
+ const BLOCK_TAGS = "address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul";
104
+ const ATTR = String.raw `(?:[ \t\n]+[A-Za-z_:][A-Za-z0-9_.:-]*(?:[ \t\n]*=[ \t\n]*(?:[^ \t\n"'=<>\x60]+|'[^']*'|"[^"]*"))?)`;
105
+ const HTML_KINDS = [
106
+ { start: /^<(?:script|pre|style|textarea)(?:[ \t>]|$)/i, end: /<\/(?:script|pre|style|textarea)>/i, interrupts: true },
107
+ { start: /^<!--/, end: /-->/, interrupts: true },
108
+ { start: /^<\?/, end: /\?>/, interrupts: true },
109
+ { start: /^<![A-Za-z]/, end: />/, interrupts: true },
110
+ { start: /^<!\[CDATA\[/, end: /\]\]>/, interrupts: true },
111
+ { start: new RegExp(`^</?(?:${BLOCK_TAGS})(?:[ \\t>]|/>|$)`, "i"), end: null, interrupts: true },
112
+ { start: new RegExp(String.raw `^(?:<[A-Za-z][A-Za-z0-9-]*${ATTR}*[ \t]*/?>|</[A-Za-z][A-Za-z0-9-]*[ \t]*>)[ \t]*$`), end: null, interrupts: false },
113
+ ];
114
+ const isPara = (leaf, depth) => leaf?.kind === "para" && leaf.depth === depth;
115
+ const fenceOpener = (t) => {
116
+ const m = FENCE_OPEN.exec(t);
117
+ return m && !(m[1][0] === "`" && m[2].includes("`")) ? m : null;
118
+ };
119
+ /**
120
+ * Would this line, with some containers unmatched, start a block rather than lazily
121
+ * continue the paragraph? Any list item does here, even an empty one or one not
122
+ * starting at 1: the interruption rule binds only inside the paragraph's own container
123
+ * (commonmark.js: `> a` then `2. b` closes the quote and starts a list at 2).
124
+ */
125
+ function startsBlock(c) {
126
+ if (indent(c) >= 4)
127
+ return false; // indented code can't interrupt a paragraph
128
+ const t = afterWs(c);
129
+ return t.startsWith(">") || THEMATIC.test(t) || ATX.test(t) || Boolean(fenceOpener(t)) || HTML_KINDS.some((k) => k.start.test(t)) || LIST_MARKER.test(t);
130
+ }
131
+ export function codeBlocks(body) {
132
+ const fences = [];
133
+ const prose = [];
134
+ // For each prose line, how many characters at its start are list markers that opened
135
+ // an item on it (with the quote markers and spaces among them): an ordered marker's
136
+ // number there is structure, not a number in the text.
137
+ const markers = [];
138
+ const content = [];
139
+ const para = [];
140
+ const paraStart = [];
141
+ const itemPara = [];
142
+ // A block starts in the innermost container: if that's a list item, it's no longer
143
+ // waiting for its first block. Says whether it was (`-` then ` [ ] x` is a task:
144
+ // the item's first paragraph starts on the line after its marker).
145
+ const starts = () => {
146
+ const top = stack.at(-1);
147
+ if (top?.kind !== "item" || !top.first)
148
+ return false;
149
+ top.first = false;
150
+ return true;
151
+ };
152
+ const stack = [];
153
+ // Where a blank line stops matching: the first quote, or the first item that began
154
+ // blank and holds nothing yet. Kept as the stack changes, so a blank line under deep
155
+ // nesting doesn't walk every container (`- - - … x` then blank lines took 15 s).
156
+ let blockers = [];
157
+ const push = (cont) => {
158
+ if (cont.kind === "quote" || (cont.blankStart && !cont.content))
159
+ blockers.push(stack.length);
160
+ stack.push(cont);
161
+ };
162
+ const truncate = (n) => {
163
+ stack.length = n;
164
+ while (blockers.length && blockers.at(-1) >= n)
165
+ blockers.pop();
166
+ };
167
+ let leaf = null;
168
+ const finish = () => {
169
+ // As CommonMark's literal: each line ends with a newline, so an empty block and
170
+ // one blank line differ.
171
+ if (leaf?.kind === "fenced") {
172
+ const code = leaf.lines.map((l) => `${l}\n`).join("");
173
+ fences.push(leaf.info ? `${leaf.info}\u0003${code}` : code);
174
+ }
175
+ else if (leaf?.kind === "indented") {
176
+ // Trailing blank lines aren't part of an indented block; they stay prose.
177
+ let n = leaf.lines.length;
178
+ while (n > 0 && /^[ \t]*$/.test(leaf.lines[n - 1]))
179
+ n--;
180
+ fences.push(leaf.lines.slice(0, n).map((l) => `${l}\n`).join(""));
181
+ for (let k = n; k < leaf.lines.length; k++)
182
+ prose.push("");
183
+ }
184
+ leaf = null;
185
+ };
186
+ // A final newline ends the last line; it doesn't start another. (Callers normalize line
187
+ // endings already; this keeps the pass right on its own.)
188
+ const source = body.replace(/\r\n?/g, "\n").split("\n");
189
+ if (source.length > 1 && source.at(-1) === "")
190
+ source.pop();
191
+ // Between two blocks the source didn't separate with a blank line, a blank one goes in,
192
+ // so inline constructs (a code span, a link's title) can't run from one block into the
193
+ // next, as CommonMark never lets them (found by differential fuzzing).
194
+ const boundary = () => {
195
+ if (prose.length && prose.at(-1).trim())
196
+ prose.push("");
197
+ };
198
+ for (const line of source) {
199
+ const c = cursor(line);
200
+ // 1. Match the open containers. Once what's left of the line is blank (a blank line,
201
+ // or `>` alone in a quote), every item matches up to the next blocker, at once: its
202
+ // whitespace goes with them, and deep nesting isn't walked container by container.
203
+ let matched = 0;
204
+ while (matched < stack.length) {
205
+ const cont = stack[matched];
206
+ if (cont.kind === "item" && blankRest(c)) {
207
+ matched = blockers.find((b) => b >= matched) ?? stack.length;
208
+ consume(c, indent(c));
209
+ break;
210
+ }
211
+ if (cont.kind === "quote") {
212
+ const n = indent(c);
213
+ if (n > 3 || c.s[wsEnd(c)] !== ">")
214
+ break;
215
+ consume(c, n);
216
+ step(c);
217
+ optionalSpace(c);
218
+ }
219
+ else if (indent(c) >= cont.indent)
220
+ consume(c, cont.indent);
221
+ else
222
+ break;
223
+ matched++;
224
+ }
225
+ // 2. A code leaf whose containers all matched takes the line.
226
+ if (leaf?.kind === "fenced" && matched === stack.length) {
227
+ const n = indent(c);
228
+ const close = /^(`{3,}|~{3,})[ \t]*$/.exec(afterWs(c));
229
+ if (n <= 3 && close && close[1][0] === leaf.ch && close[1].length >= leaf.len) {
230
+ finish();
231
+ prose.push("");
232
+ }
233
+ else {
234
+ consume(c, Math.min(n, leaf.indent));
235
+ leaf.lines.push(rest(c));
236
+ }
237
+ continue;
238
+ }
239
+ if (leaf?.kind === "indented" && matched === stack.length) {
240
+ if (blankRest(c)) {
241
+ consume(c, 4);
242
+ leaf.lines.push(rest(c));
243
+ continue;
244
+ }
245
+ if (indent(c) >= 4) {
246
+ consume(c, 4);
247
+ leaf.lines.push(rest(c));
248
+ continue;
249
+ }
250
+ finish();
251
+ }
252
+ if (leaf?.kind === "html" && matched === stack.length) {
253
+ if (leaf.end === null && blankRest(c))
254
+ finish(); // 6 and 7 end before a blank line
255
+ else {
256
+ prose.push(line);
257
+ content[prose.length - 1] = c.i;
258
+ if (leaf.end?.test(line.slice(c.i)))
259
+ finish();
260
+ continue;
261
+ }
262
+ }
263
+ // 3. Unmatched containers: a lazy paragraph line keeps them open; otherwise they close.
264
+ if (matched < stack.length) {
265
+ if (leaf?.kind === "para" && !blankRest(c) && !startsBlock(c)) {
266
+ prose.push(line);
267
+ content[prose.length - 1] = c.i;
268
+ para[prose.length - 1] = true;
269
+ continue;
270
+ }
271
+ if (leaf && leaf.depth > matched)
272
+ finish();
273
+ truncate(matched);
274
+ }
275
+ if (!blankRest(c)) {
276
+ // Every open item holds this line now, so only quotes still stop a blank line.
277
+ for (const cont of stack)
278
+ if (cont.kind === "item")
279
+ cont.content = true;
280
+ blockers = blockers.filter((k) => stack[k].kind === "quote");
281
+ }
282
+ // 4. New containers.
283
+ let opened = 0;
284
+ for (;;) {
285
+ if (blankRest(c))
286
+ break;
287
+ const n = indent(c);
288
+ if (n >= 4)
289
+ break;
290
+ const at = wsEnd(c);
291
+ const paraHere = isPara(leaf, stack.length);
292
+ if (c.s[at] === ">") {
293
+ consume(c, n);
294
+ step(c);
295
+ optionalSpace(c);
296
+ if (leaf)
297
+ finish();
298
+ starts();
299
+ push({ kind: "quote" });
300
+ continue;
301
+ }
302
+ if (paraHere && SETEXT.test(c.s.slice(at)))
303
+ break; // once a line: a new container ends the paragraph
304
+ if (thematicAhead(c))
305
+ break;
306
+ LIST_MARKER_AT.lastIndex = at;
307
+ const m = LIST_MARKER_AT.exec(c.s);
308
+ if (!m)
309
+ break;
310
+ const empty = c.lastText < at + m[0].length;
311
+ if (paraHere && (empty || (m[3] && Number(m[2]) !== 1)))
312
+ break;
313
+ consume(c, n);
314
+ step(c, m[0].length);
315
+ const sp = indent(c);
316
+ let width;
317
+ if (empty)
318
+ width = n + m[0].length + 1;
319
+ else if (sp >= 5) {
320
+ consume(c, 1);
321
+ width = n + m[0].length + 1;
322
+ }
323
+ else {
324
+ consume(c, sp);
325
+ width = n + m[0].length + sp;
326
+ }
327
+ if (leaf)
328
+ finish();
329
+ starts();
330
+ push({ kind: "item", indent: width, blankStart: empty, content: !empty, first: true });
331
+ opened = c.i;
332
+ }
333
+ // 5. The leaf.
334
+ if (blankRest(c)) {
335
+ if (leaf?.kind === "para")
336
+ finish();
337
+ prose.push(line);
338
+ markers[prose.length - 1] = opened;
339
+ content[prose.length - 1] = c.i;
340
+ continue;
341
+ }
342
+ const paraHere = isPara(leaf, stack.length);
343
+ const n = indent(c);
344
+ if (n >= 4) {
345
+ if (paraHere) {
346
+ prose.push(line); // continuation: indented code can't interrupt a paragraph
347
+ markers[prose.length - 1] = opened;
348
+ content[prose.length - 1] = c.i;
349
+ para[prose.length - 1] = true;
350
+ continue;
351
+ }
352
+ if (leaf)
353
+ finish();
354
+ consume(c, 4);
355
+ leaf = { kind: "indented", depth: stack.length, lines: [rest(c)] };
356
+ starts();
357
+ boundary();
358
+ prose.push(FENCE_MARK); // the mark alone: no markers to strip
359
+ continue;
360
+ }
361
+ const t = afterWs(c);
362
+ const fence = fenceOpener(t);
363
+ if (fence) {
364
+ if (leaf)
365
+ finish();
366
+ leaf = { kind: "fenced", depth: stack.length, ch: fence[1][0], len: fence[1].length, indent: n, info: fence[2].trim(), lines: [] };
367
+ starts();
368
+ boundary();
369
+ prose.push(FENCE_MARK); // the mark alone: no markers to strip
370
+ continue;
371
+ }
372
+ const html = n <= 3 ? HTML_KINDS.find((k) => k.start.test(t) && (k.interrupts || !paraHere)) : undefined;
373
+ if (html) {
374
+ if (leaf)
375
+ finish();
376
+ boundary();
377
+ prose.push(line);
378
+ markers[prose.length - 1] = opened;
379
+ content[prose.length - 1] = c.i;
380
+ if (!html.end?.test(t))
381
+ leaf = { kind: "html", depth: stack.length, end: html.end };
382
+ starts();
383
+ continue;
384
+ }
385
+ if ((paraHere && SETEXT.test(t)) || THEMATIC.test(t) || ATX.test(t)) {
386
+ // A setext underline makes its paragraph a heading: not an item's first paragraph,
387
+ // so no task (`- [ ] x` then ` -`; found by differential fuzzing).
388
+ if (paraHere && SETEXT.test(t) && leaf?.kind === "para")
389
+ delete itemPara[leaf.start];
390
+ if (leaf)
391
+ finish();
392
+ if (!(paraHere && SETEXT.test(t))) {
393
+ boundary(); // a setext underline belongs to its paragraph
394
+ starts();
395
+ }
396
+ prose.push(line);
397
+ markers[prose.length - 1] = opened;
398
+ content[prose.length - 1] = c.i;
399
+ continue;
400
+ }
401
+ if (!paraHere) {
402
+ if (leaf)
403
+ finish();
404
+ leaf = { kind: "para", depth: stack.length, start: prose.length + (prose.length && prose.at(-1).trim() ? 1 : 0) };
405
+ boundary();
406
+ paraStart[prose.length] = true;
407
+ if (starts())
408
+ itemPara[prose.length] = true;
409
+ }
410
+ prose.push(line);
411
+ markers[prose.length - 1] = opened;
412
+ content[prose.length - 1] = c.i;
413
+ para[prose.length - 1] = true;
414
+ }
415
+ finish();
416
+ return { fences, prose: prose.join("\n"), markers, content, para, paraStart, itemPara };
417
+ }
@@ -0,0 +1,62 @@
1
+ export declare const CODEOWNERS_LOCATIONS: string[];
2
+ export type OwnerKind = "team" | "user" | "email";
3
+ export interface Owner {
4
+ raw: string;
5
+ kind: OwnerKind;
6
+ /** For teams: org and slug from `@org/slug`. */
7
+ org?: string;
8
+ team?: string;
9
+ }
10
+ export interface Rule {
11
+ line: number;
12
+ pattern: string;
13
+ owners: Owner[];
14
+ regex: RegExp;
15
+ }
16
+ export interface CodeownersParseError {
17
+ line: number;
18
+ message: string;
19
+ }
20
+ export interface Codeowners {
21
+ path: string | null;
22
+ rules: Rule[];
23
+ errors: CodeownersParseError[];
24
+ }
25
+ export declare function parseOwner(raw: string): Owner;
26
+ /** Translate one CODEOWNERS pattern to a regex over repo-relative posix paths. */
27
+ export declare function patternToRegex(pattern: string): RegExp;
28
+ export declare function parseCodeowners(text: string, path?: string | null): Codeowners;
29
+ export declare function loadCodeowners(repoRoot: string): Codeowners;
30
+ /** The winning (last matching) rule for a repo-relative path, if any. */
31
+ export declare function matchRule(co: Codeowners, filePath: string): Rule | null;
32
+ export type OwnershipProblem = {
33
+ kind: "no_rule";
34
+ } | {
35
+ kind: "no_owners";
36
+ rule: Rule;
37
+ } | {
38
+ kind: "multiple_owners";
39
+ rule: Rule;
40
+ } | {
41
+ kind: "individual_owner";
42
+ rule: Rule;
43
+ owner: Owner;
44
+ } | {
45
+ kind: "foreign_org";
46
+ rule: Rule;
47
+ owner: Owner;
48
+ };
49
+ export type Ownership = {
50
+ ok: true;
51
+ team: string;
52
+ rule: Rule;
53
+ } | {
54
+ ok: false;
55
+ problem: OwnershipProblem;
56
+ };
57
+ /**
58
+ * Resolve the single owning team of a path. Knowledge pages must be owned by exactly
59
+ * one team of `org` — never by an individual (the Plaid lesson), never by several.
60
+ */
61
+ export declare function resolveTeamOwner(co: Codeowners, filePath: string, org: string): Ownership;
62
+ export declare function describeOwnershipProblem(p: OwnershipProblem, org: string): string;
@@ -0,0 +1,133 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ // CODEOWNERS with GitHub's semantics (docs: "About code owners"):
4
+ // - the LAST matching rule wins;
5
+ // - gitignore-style patterns, minus `!` negation and `[...]` ranges;
6
+ // - a pattern containing a slash (other than a trailing one) is anchored to the root;
7
+ // - a trailing slash matches a directory's contents;
8
+ // - a pattern whose last segment is a wildcard (`docs/*`) matches only direct children,
9
+ // while a literal last segment (`/apps/github`, `**/logs`) also matches everything
10
+ // beneath it;
11
+ // - a rule with no owners explicitly un-owns the path.
12
+ export const CODEOWNERS_LOCATIONS = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"];
13
+ export function parseOwner(raw) {
14
+ const team = /^@([A-Za-z0-9][\w.-]*)\/([A-Za-z0-9][\w.-]*)$/.exec(raw);
15
+ if (team)
16
+ return { raw, kind: "team", org: team[1].toLowerCase(), team: team[2].toLowerCase() };
17
+ if (raw.startsWith("@"))
18
+ return { raw, kind: "user" };
19
+ return { raw, kind: "email" };
20
+ }
21
+ function escapeRe(s) {
22
+ return s.replace(/[.+^${}()|\\*?[\]]/g, "\\$&");
23
+ }
24
+ /** Translate one CODEOWNERS pattern to a regex over repo-relative posix paths. */
25
+ export function patternToRegex(pattern) {
26
+ if (pattern === "*" || pattern === "/*" || pattern === "**")
27
+ return /^.*$/;
28
+ let p = pattern;
29
+ const dirOnly = p.endsWith("/") && !p.endsWith("\\/");
30
+ if (dirOnly)
31
+ p = p.slice(0, -1);
32
+ const leadingSlash = p.startsWith("/");
33
+ if (leadingSlash)
34
+ p = p.slice(1);
35
+ const anchored = leadingSlash || p.includes("/");
36
+ const segments = p.split("/");
37
+ const last = segments[segments.length - 1] ?? "";
38
+ const lastIsWildcard = /(^|[^\\])[*?]/.test(last) && last !== "**";
39
+ let body = "";
40
+ for (let i = 0; i < segments.length; i++) {
41
+ const seg = segments[i];
42
+ const isLast = i === segments.length - 1;
43
+ if (seg === "**") {
44
+ body += isLast ? ".*" : "(?:.*/)?";
45
+ continue;
46
+ }
47
+ // A backslash makes the next character literal (`\ ` for spaces, `\#`, `\*`).
48
+ for (let j = 0; j < seg.length; j++) {
49
+ const c = seg[j];
50
+ if (c === "\\" && j + 1 < seg.length)
51
+ body += escapeRe(seg[++j]);
52
+ else
53
+ body += c === "*" ? "[^/]*" : c === "?" ? "[^/]" : escapeRe(c);
54
+ }
55
+ if (!isLast)
56
+ body += "/";
57
+ }
58
+ const prefix = anchored ? "^" : "^(?:.*/)?";
59
+ const suffix = dirOnly ? "/.*$" : lastIsWildcard ? "$" : "(?:/.*)?$";
60
+ return new RegExp(prefix + body + suffix);
61
+ }
62
+ export function parseCodeowners(text, path = null) {
63
+ const rules = [];
64
+ const errors = [];
65
+ text.split(/\r?\n/).forEach((rawLine, i) => {
66
+ const line = i + 1;
67
+ // Strip comments: a `#` at line start, or preceded by whitespace (not `\#`).
68
+ const content = rawLine.replace(/(^|\s)#.*$/, "").trim();
69
+ if (!content)
70
+ return;
71
+ // Split on unescaped whitespace only: `docs/team\ notes/` is one pattern.
72
+ const [pattern, ...ownerTokens] = content.match(/(?:\\.|\S)+/g) ?? [];
73
+ if (!pattern)
74
+ return;
75
+ if (pattern.startsWith("!") || /\[|\]/.test(pattern)) {
76
+ errors.push({ line, message: `unsupported pattern syntax in \`${pattern}\` (GitHub ignores this line)` });
77
+ return;
78
+ }
79
+ rules.push({ line, pattern, owners: ownerTokens.map(parseOwner), regex: patternToRegex(pattern) });
80
+ });
81
+ return { path, rules, errors };
82
+ }
83
+ export function loadCodeowners(repoRoot) {
84
+ for (const rel of CODEOWNERS_LOCATIONS) {
85
+ const abs = join(repoRoot, rel);
86
+ if (existsSync(abs))
87
+ return parseCodeowners(readFileSync(abs, "utf8"), rel);
88
+ }
89
+ return { path: null, rules: [], errors: [] };
90
+ }
91
+ /** The winning (last matching) rule for a repo-relative path, if any. */
92
+ export function matchRule(co, filePath) {
93
+ const p = filePath.replace(/^\/+/, "");
94
+ for (let i = co.rules.length - 1; i >= 0; i--) {
95
+ const rule = co.rules[i];
96
+ if (rule.regex.test(p))
97
+ return rule;
98
+ }
99
+ return null;
100
+ }
101
+ /**
102
+ * Resolve the single owning team of a path. Knowledge pages must be owned by exactly
103
+ * one team of `org` — never by an individual (the Plaid lesson), never by several.
104
+ */
105
+ export function resolveTeamOwner(co, filePath, org) {
106
+ const rule = matchRule(co, filePath);
107
+ if (!rule)
108
+ return { ok: false, problem: { kind: "no_rule" } };
109
+ if (rule.owners.length === 0)
110
+ return { ok: false, problem: { kind: "no_owners", rule } };
111
+ if (rule.owners.length > 1)
112
+ return { ok: false, problem: { kind: "multiple_owners", rule } };
113
+ const owner = rule.owners[0];
114
+ if (owner.kind !== "team")
115
+ return { ok: false, problem: { kind: "individual_owner", rule, owner } };
116
+ if (owner.org !== org.toLowerCase())
117
+ return { ok: false, problem: { kind: "foreign_org", rule, owner } };
118
+ return { ok: true, team: owner.team, rule };
119
+ }
120
+ export function describeOwnershipProblem(p, org) {
121
+ switch (p.kind) {
122
+ case "no_rule":
123
+ return "no CODEOWNERS rule matches this path";
124
+ case "no_owners":
125
+ return `CODEOWNERS line ${p.rule.line} (\`${p.rule.pattern}\`) leaves this path unowned`;
126
+ case "multiple_owners":
127
+ return `CODEOWNERS line ${p.rule.line} names ${p.rule.owners.length} owners; knowledge paths need exactly one team`;
128
+ case "individual_owner":
129
+ return `CODEOWNERS line ${p.rule.line} assigns individual owner ${p.owner.raw}; use a team (@${org}/<team>)`;
130
+ case "foreign_org":
131
+ return `CODEOWNERS line ${p.rule.line} assigns ${p.owner.raw}, which is not a team in org \`${org}\``;
132
+ }
133
+ }