@tikoci/rosetta 0.9.3 → 0.11.0-alpha.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/package.json +1 -1
- package/src/db.ts +10 -0
- package/src/eval/retrieval.ts +16 -12
- package/src/extract-docusaurus.test.ts +251 -0
- package/src/extract-docusaurus.ts +643 -0
- package/src/extract-skills.test.ts +41 -2
- package/src/extract-skills.ts +9 -13
- package/src/github.test.ts +70 -0
- package/src/github.ts +67 -0
- package/src/mcp-stdio-client.test.ts +34 -5
- package/src/paths.ts +4 -1
- package/src/release.test.ts +220 -33
- package/src/restraml.test.ts +31 -0
- package/src/restraml.ts +7 -5
- package/src/rosetta-id.test.ts +95 -0
- package/src/rosetta-id.ts +151 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* extract-docusaurus.ts — Parse manual.mikrotik.com /docs prose into SQLite.
|
|
5
|
+
*
|
|
6
|
+
* Replaces extract-html.ts's role for fresh prose extraction (extract-html.ts stays
|
|
7
|
+
* for rebuilding historical Confluence-era release DBs only — see MANUAL.md and
|
|
8
|
+
* DESIGN.md). Scope: /docs/** prose only — CLI Reference (/docs/cli-reference/*) and
|
|
9
|
+
* /docs/tags/* are excluded (separate, not-yet-built tasks per B-0012's "Proposed
|
|
10
|
+
* migration task files" #2/#3), and the standalone /hardware section is a different
|
|
11
|
+
* URL prefix entirely, out of scope here.
|
|
12
|
+
*
|
|
13
|
+
* Discovers pages via sitemap.xml, fetches each page's raw Markdown (`{url}.md`),
|
|
14
|
+
* and populates pages/sections/properties/callouts using the rosetta-id scheme
|
|
15
|
+
* validated by T-0034 (see src/rosetta-id.ts, briefings/B-0012-docusaurus-manual-migration.md
|
|
16
|
+
* "H7 — Identity / rosetta-id design").
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* bun run src/extract-docusaurus.ts # live fetch, caches .md to CACHE_DIR
|
|
20
|
+
* bun run src/extract-docusaurus.ts --from-cache # re-extract from CACHE_DIR, no network
|
|
21
|
+
* bun run src/extract-docusaurus.ts --limit=25 # cap page count (smoke-testing)
|
|
22
|
+
* bun run src/extract-docusaurus.ts --check-counts # compare extracted count vs llms.txt (non-blocking)
|
|
23
|
+
* bun run src/extract-docusaurus.ts --check-counts --strict # same, but exit 1 on mismatch
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
28
|
+
import { db, initDb } from "./db.ts";
|
|
29
|
+
import { deriveRosettaId, loadSitemapUrls, rosettaIdToUrl } from "./rosetta-id.ts";
|
|
30
|
+
|
|
31
|
+
const BASE = "https://manual.mikrotik.com";
|
|
32
|
+
const SITEMAP_URL = `${BASE}/sitemap.xml`;
|
|
33
|
+
const LLMS_TXT_URL = `${BASE}/llms.txt`;
|
|
34
|
+
const PROJECT_ROOT = join(import.meta.dirname, "..");
|
|
35
|
+
const DEFAULT_CACHE_DIR = join(PROJECT_ROOT, "manual", "pages");
|
|
36
|
+
const FETCH_DELAY_MS = 100;
|
|
37
|
+
|
|
38
|
+
// ── CLI flags ──
|
|
39
|
+
|
|
40
|
+
const argv = process.argv.slice(2);
|
|
41
|
+
const FROM_CACHE = argv.includes("--from-cache");
|
|
42
|
+
const CHECK_COUNTS = argv.includes("--check-counts");
|
|
43
|
+
const STRICT = argv.includes("--strict");
|
|
44
|
+
const limitArg = argv.find((a) => a.startsWith("--limit="));
|
|
45
|
+
const LIMIT = limitArg ? Number(limitArg.slice("--limit=".length)) : undefined;
|
|
46
|
+
const cacheDirArg = argv.find((a) => a.startsWith("--cache-dir="));
|
|
47
|
+
const CACHE_DIR = cacheDirArg ? cacheDirArg.slice("--cache-dir=".length) : DEFAULT_CACHE_DIR;
|
|
48
|
+
|
|
49
|
+
// ── Scope filter ──
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* In scope: /docs/** prose only, excluding CLI Reference and the tag-index pages.
|
|
53
|
+
* The standalone /hardware/* section uses a different URL prefix and is already
|
|
54
|
+
* excluded by the leading "/docs/" check.
|
|
55
|
+
*/
|
|
56
|
+
export function isInScopeDocsUrl(urlOrPath: string): boolean {
|
|
57
|
+
let path: string;
|
|
58
|
+
try {
|
|
59
|
+
path = new URL(urlOrPath).pathname;
|
|
60
|
+
} catch {
|
|
61
|
+
path = urlOrPath;
|
|
62
|
+
}
|
|
63
|
+
if (!path.startsWith("/docs/")) return false;
|
|
64
|
+
// Strip an optional Docusaurus version prefix (/docs/next/… or /docs/<semver>/…)
|
|
65
|
+
// before the exclusion checks so versioned CLI-reference/tag pages are excluded the
|
|
66
|
+
// same as unversioned ones — mirrors deriveRosettaId()'s version handling.
|
|
67
|
+
// manual.mikrotik.com is unversioned today, but B-0012 H7 treats this as cheap now.
|
|
68
|
+
path = path.replace(/^\/docs\/(?:next|v?\d+(?:\.\d+)*(?:-[a-z0-9.]+)?)\//, "/docs/");
|
|
69
|
+
if (path.startsWith("/docs/cli-reference/")) return false;
|
|
70
|
+
// The tag-index root ("/docs/tags", no trailing slash, no real .md content —
|
|
71
|
+
// confirmed live 2026-07-07: 404s) and individual tag pages both excluded.
|
|
72
|
+
if (path === "/docs/tags" || path.startsWith("/docs/tags/")) return false;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Markdown parsing ──
|
|
77
|
+
|
|
78
|
+
export interface ParsedProperty {
|
|
79
|
+
name: string;
|
|
80
|
+
rawType: string | null;
|
|
81
|
+
defaultVal: string | null;
|
|
82
|
+
description: string;
|
|
83
|
+
section: string | null;
|
|
84
|
+
malformedEmphasis: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ParsedCallout {
|
|
88
|
+
type: string;
|
|
89
|
+
content: string;
|
|
90
|
+
sortOrder: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ParsedSection {
|
|
94
|
+
heading: string;
|
|
95
|
+
level: number;
|
|
96
|
+
anchorId: string;
|
|
97
|
+
text: string;
|
|
98
|
+
code: string;
|
|
99
|
+
wordCount: number;
|
|
100
|
+
sortOrder: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ParsedPage {
|
|
104
|
+
rosettaId: string;
|
|
105
|
+
url: string;
|
|
106
|
+
slug: string;
|
|
107
|
+
title: string;
|
|
108
|
+
path: string;
|
|
109
|
+
depth: number;
|
|
110
|
+
text: string;
|
|
111
|
+
code: string;
|
|
112
|
+
codeLang: string | null;
|
|
113
|
+
wordCount: number;
|
|
114
|
+
codeLines: number;
|
|
115
|
+
sections: ParsedSection[];
|
|
116
|
+
properties: ParsedProperty[];
|
|
117
|
+
callouts: ParsedCallout[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** GitHub-slugger-style approximation of Docusaurus's auto-generated heading anchors. */
|
|
121
|
+
export function slugify(text: string): string {
|
|
122
|
+
return text
|
|
123
|
+
.toLowerCase()
|
|
124
|
+
.trim()
|
|
125
|
+
.replace(/[^a-z0-9\s-]/g, "")
|
|
126
|
+
.replace(/\s+/g, "-")
|
|
127
|
+
.replace(/-+/g, "-")
|
|
128
|
+
.replace(/^-|-$/g, "");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Tracks whether the current line is inside a fenced code block (``` ... ```).
|
|
133
|
+
* RouterOS example scripts use `#` for comments (e.g. `# Drop ARP frames...`), which
|
|
134
|
+
* looks exactly like a Markdown heading to a naive line-by-line regex — without this,
|
|
135
|
+
* heading/section-context detection misfires inside code fences (found via dot1x.md's
|
|
136
|
+
* `RouterOS Authenticator configuration` example, which contains several `# ...` comment
|
|
137
|
+
* lines that were wrongly detected as new top-level page sections).
|
|
138
|
+
*/
|
|
139
|
+
function makeFenceTracker() {
|
|
140
|
+
let inFence = false;
|
|
141
|
+
return (line: string): boolean => {
|
|
142
|
+
if (/^```/.test(line)) {
|
|
143
|
+
inFence = !inFence;
|
|
144
|
+
return true; // the fence delimiter line itself counts as "inside" for callers' purposes
|
|
145
|
+
}
|
|
146
|
+
return inFence;
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Split a Markdown table row into cells, respecting `\|` as an escaped literal pipe. */
|
|
151
|
+
function splitTableRow(line: string): string[] {
|
|
152
|
+
const cells: string[] = [];
|
|
153
|
+
let current = "";
|
|
154
|
+
for (let i = 0; i < line.length; i++) {
|
|
155
|
+
if (line[i] === "\\" && line[i + 1] === "|") {
|
|
156
|
+
current += "|";
|
|
157
|
+
i++;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (line[i] === "|") {
|
|
161
|
+
cells.push(current.trim());
|
|
162
|
+
current = "";
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
current += line[i];
|
|
166
|
+
}
|
|
167
|
+
cells.push(current.trim());
|
|
168
|
+
if (cells[0] === "") cells.shift();
|
|
169
|
+
if (cells[cells.length - 1] === "") cells.pop();
|
|
170
|
+
return cells;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Parse a property-cell's raw text into name/type/default.
|
|
175
|
+
* Tolerant of the malformed-bold-emphasis pattern found in dhcp.md's check-gateway
|
|
176
|
+
* row (B-0012 H4): `**check-gateway** *(none \| arp \| bfd \| ping***;** Default: **none)**`
|
|
177
|
+
* — the closing bold delimiter lands in the wrong place.
|
|
178
|
+
*/
|
|
179
|
+
function parsePropertyCell(
|
|
180
|
+
cellText: string,
|
|
181
|
+
): { name: string; rawType: string | null; defaultVal: string | null; malformed: boolean } | null {
|
|
182
|
+
const nameMatch = cellText.match(/\*\*([a-z0-9][a-z0-9-]*)\*\*/i);
|
|
183
|
+
if (!nameMatch) return null;
|
|
184
|
+
const name = nameMatch[1];
|
|
185
|
+
|
|
186
|
+
const defaultMatch = cellText.match(/Default:\s*\*{0,2}([^)*]*)\*{0,2}\)?/i);
|
|
187
|
+
const defaultVal = defaultMatch ? defaultMatch[1].trim() || null : null;
|
|
188
|
+
|
|
189
|
+
const parenMatch = cellText.match(/\(([^)]*)\)/);
|
|
190
|
+
const rawType = parenMatch ? parenMatch[1].replace(/Default:.*/i, "").replace(/[*;]+$/, "").trim() || null : null;
|
|
191
|
+
|
|
192
|
+
// Malformed-emphasis heuristic: an odd number of `**` markers, or `***` runs
|
|
193
|
+
// (three-or-more asterisks in a row), which well-formed bold/italic never produces.
|
|
194
|
+
const boldMarkers = (cellText.match(/\*\*/g) || []).length;
|
|
195
|
+
const malformed = boldMarkers % 2 !== 0 || /\*{3,}/.test(cellText);
|
|
196
|
+
|
|
197
|
+
return { name, rawType, defaultVal, malformed };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Parse Markdown property tables: `| Property | Description |` or `| Parameter | Description |`
|
|
202
|
+
* (both header spellings observed live — sms.md uses "Parameter", dhcp.md uses "Property").
|
|
203
|
+
* Section attribution is the nearest preceding heading of any level, matching extract-html.ts's
|
|
204
|
+
* "nearest preceding heading" behavior for the Confluence corpus.
|
|
205
|
+
*/
|
|
206
|
+
export function parseProperties(md: string): ParsedProperty[] {
|
|
207
|
+
const lines = md.split("\n");
|
|
208
|
+
const properties: ParsedProperty[] = [];
|
|
209
|
+
let currentSection: string | null = null;
|
|
210
|
+
const isFenced = makeFenceTracker();
|
|
211
|
+
|
|
212
|
+
for (let i = 0; i < lines.length; i++) {
|
|
213
|
+
const line = lines[i];
|
|
214
|
+
if (isFenced(line)) continue;
|
|
215
|
+
|
|
216
|
+
const headingMatch = line.match(/^#{1,6}\s+(.+)$/);
|
|
217
|
+
if (headingMatch) {
|
|
218
|
+
currentSection = headingMatch[1].trim();
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (
|
|
223
|
+
/^\|.*\b(property|parameter)\b.*\|$/i.test(line) &&
|
|
224
|
+
lines[i + 1] &&
|
|
225
|
+
/^\|[\s:|-]+\|$/.test(lines[i + 1])
|
|
226
|
+
) {
|
|
227
|
+
i += 2; // skip header + separator
|
|
228
|
+
while (i < lines.length && lines[i].trim().startsWith("|")) {
|
|
229
|
+
const cells = splitTableRow(lines[i]);
|
|
230
|
+
if (cells.length >= 2) {
|
|
231
|
+
const parsed = parsePropertyCell(cells[0]);
|
|
232
|
+
if (parsed) {
|
|
233
|
+
properties.push({
|
|
234
|
+
name: parsed.name,
|
|
235
|
+
rawType: parsed.rawType,
|
|
236
|
+
defaultVal: parsed.defaultVal,
|
|
237
|
+
description: cells[1],
|
|
238
|
+
section: currentSection,
|
|
239
|
+
malformedEmphasis: parsed.malformed,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
i++;
|
|
244
|
+
}
|
|
245
|
+
i--; // outer loop will increment
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return properties;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Parse :::type ... ::: (and ::::-nested) admonition blocks into callouts. */
|
|
253
|
+
export function parseCallouts(md: string): ParsedCallout[] {
|
|
254
|
+
const lines = md.split("\n");
|
|
255
|
+
const callouts: ParsedCallout[] = [];
|
|
256
|
+
const stack: Array<{ type: string; fenceWidth: number; contentLines: string[] }> = [];
|
|
257
|
+
let sortOrder = 0;
|
|
258
|
+
|
|
259
|
+
for (const line of lines) {
|
|
260
|
+
const fenceMatch = line.match(/^(:{3,})(\w+)?\s*$/);
|
|
261
|
+
if (fenceMatch) {
|
|
262
|
+
const fenceWidth = fenceMatch[1].length;
|
|
263
|
+
const type = fenceMatch[2];
|
|
264
|
+
if (type) {
|
|
265
|
+
stack.push({ type, fenceWidth, contentLines: [] });
|
|
266
|
+
} else if (stack.length > 0 && stack[stack.length - 1].fenceWidth === fenceWidth) {
|
|
267
|
+
const closed = stack.pop();
|
|
268
|
+
if (closed) {
|
|
269
|
+
callouts.push({ type: closed.type, content: closed.contentLines.join("\n").trim(), sortOrder: sortOrder++ });
|
|
270
|
+
}
|
|
271
|
+
} else if (stack.length > 0) {
|
|
272
|
+
stack[stack.length - 1].contentLines.push(line);
|
|
273
|
+
}
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (stack.length > 0) stack[stack.length - 1].contentLines.push(line);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return callouts;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Resolve a single relative Markdown link target against the page's own URL.
|
|
284
|
+
* Preserves a `#anchor` fragment (e.g. `./dhcp.md#dhcp-server`) — deriveRosettaId
|
|
285
|
+
* intentionally drops the fragment for identity purposes (H7), but a link rewritten
|
|
286
|
+
* for human/agent navigation should still land on the right section, not just the page.
|
|
287
|
+
*/
|
|
288
|
+
function resolveLinkTarget(target: string, pageUrl: string): string {
|
|
289
|
+
if (/^https?:\/\//.test(target)) return target;
|
|
290
|
+
if (target.startsWith("#")) return target;
|
|
291
|
+
const resolved = new URL(target, pageUrl);
|
|
292
|
+
const base = rosettaIdToUrl(deriveRosettaId(resolved.toString()));
|
|
293
|
+
return resolved.hash ? `${base}${resolved.hash}` : base;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Rewrite relative Markdown links `[text](target)` inside a description to live
|
|
298
|
+
* manual.mikrotik.com URLs so descriptions never carry a broken relative path once
|
|
299
|
+
* stored outside their original page context (B-0012 H4/H7).
|
|
300
|
+
*/
|
|
301
|
+
export function resolveDescriptionLinks(description: string, pageUrl: string): string {
|
|
302
|
+
return description.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_whole, text, target) => {
|
|
303
|
+
const resolved = resolveLinkTarget(target, pageUrl);
|
|
304
|
+
return `[${text}](${resolved})`;
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Extract fenced code blocks (```lang ... ```), returning joined content and observed languages. */
|
|
309
|
+
export function extractCodeBlocks(md: string): { code: string; codeLang: string | null } {
|
|
310
|
+
const blocks: string[] = [];
|
|
311
|
+
const langs = new Set<string>();
|
|
312
|
+
const re = /```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g;
|
|
313
|
+
let m: RegExpExecArray | null;
|
|
314
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec-loop idiom
|
|
315
|
+
while ((m = re.exec(md))) {
|
|
316
|
+
const [, lang, body] = m;
|
|
317
|
+
if (lang) langs.add(lang);
|
|
318
|
+
const trimmed = body.trim();
|
|
319
|
+
if (trimmed) blocks.push(trimmed);
|
|
320
|
+
}
|
|
321
|
+
return { code: blocks.join("\n\n"), codeLang: langs.size > 0 ? [...langs].join(",") : null };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Split page body into sections by h1–h3 headings, mirroring extract-html.ts's
|
|
326
|
+
* extractSections (which also only splits on h1–h3, folding deeper headings into
|
|
327
|
+
* the enclosing section's text). Skips a leading duplicate of the page title — the
|
|
328
|
+
* raw .md source repeats the H1 immediately after the AI-summary blockquote (observed
|
|
329
|
+
* live on dhcp.md, dot1x.md, address-lists.md), which would otherwise mint a spurious
|
|
330
|
+
* empty section.
|
|
331
|
+
*/
|
|
332
|
+
export function parseSections(md: string, title: string): ParsedSection[] {
|
|
333
|
+
const lines = md.split("\n");
|
|
334
|
+
const headings: Array<{ level: number; heading: string; lineIndex: number }> = [];
|
|
335
|
+
const isFenced = makeFenceTracker();
|
|
336
|
+
|
|
337
|
+
for (let i = 0; i < lines.length; i++) {
|
|
338
|
+
if (isFenced(lines[i])) continue;
|
|
339
|
+
const m = lines[i].match(/^(#{1,3})\s+(.+)$/);
|
|
340
|
+
if (m) headings.push({ level: m[1].length, heading: m[2].trim(), lineIndex: i });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Drop leading duplicate(s) of the page title (see doc comment above) — the live
|
|
344
|
+
// source has repeated it exactly once so far, but loop rather than a single `if`
|
|
345
|
+
// in case a future page repeats it more than once.
|
|
346
|
+
while (headings.length > 0 && headings[0].heading === title) headings.shift();
|
|
347
|
+
|
|
348
|
+
const usedAnchors = new Map<string, number>();
|
|
349
|
+
const anchorFor = (heading: string): string => {
|
|
350
|
+
const base = slugify(heading);
|
|
351
|
+
const count = usedAnchors.get(base) ?? 0;
|
|
352
|
+
usedAnchors.set(base, count + 1);
|
|
353
|
+
return count === 0 ? base : `${base}-${count}`;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
return headings.map((h, i) => {
|
|
357
|
+
const startLine = h.lineIndex + 1;
|
|
358
|
+
const endLine = headings[i + 1]?.lineIndex ?? lines.length;
|
|
359
|
+
const sectionMd = lines.slice(startLine, endLine).join("\n").trim();
|
|
360
|
+
const { code } = extractCodeBlocks(sectionMd);
|
|
361
|
+
const text = sectionMd;
|
|
362
|
+
return {
|
|
363
|
+
heading: h.heading,
|
|
364
|
+
level: h.level,
|
|
365
|
+
anchorId: anchorFor(h.heading),
|
|
366
|
+
text,
|
|
367
|
+
code,
|
|
368
|
+
wordCount: text.split(/\s+/).filter(Boolean).length,
|
|
369
|
+
sortOrder: i,
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Parse one page's raw Markdown body into the full structured shape stored in the DB. */
|
|
375
|
+
export function parsePage(md: string, pageUrl: string): ParsedPage {
|
|
376
|
+
const rosettaId = deriveRosettaId(pageUrl);
|
|
377
|
+
const titleMatch = md.match(/^#\s+(.+)$/m);
|
|
378
|
+
const title = titleMatch ? titleMatch[1].trim() : rosettaId;
|
|
379
|
+
|
|
380
|
+
const segments = rosettaId.split("/");
|
|
381
|
+
const slug = segments[segments.length - 1];
|
|
382
|
+
const path = segments.join(" > ");
|
|
383
|
+
const depth = segments.length;
|
|
384
|
+
|
|
385
|
+
const { code, codeLang } = extractCodeBlocks(md);
|
|
386
|
+
const text = md.trim();
|
|
387
|
+
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
|
388
|
+
const codeLines = code.split("\n").filter((l) => l.trim()).length;
|
|
389
|
+
|
|
390
|
+
const properties = parseProperties(md).map((p) => ({
|
|
391
|
+
...p,
|
|
392
|
+
description: resolveDescriptionLinks(p.description, pageUrl),
|
|
393
|
+
}));
|
|
394
|
+
const callouts = parseCallouts(md);
|
|
395
|
+
const sections = parseSections(md, title);
|
|
396
|
+
|
|
397
|
+
return { rosettaId, url: pageUrl, slug, title, path, depth, text, code, codeLang, wordCount, codeLines, sections, properties, callouts };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── Fetching / caching ──
|
|
401
|
+
|
|
402
|
+
function cachePathFor(rosettaId: string): string {
|
|
403
|
+
const target = join(CACHE_DIR, `${rosettaId}.md`);
|
|
404
|
+
// Defense-in-depth: rosettaId derives from network-fetched sitemap <loc> URLs, so a
|
|
405
|
+
// malformed/hostile path must never let a cache write escape CACHE_DIR (CodeQL:
|
|
406
|
+
// "Network data written to file"). URL normalization already collapses `..`, but
|
|
407
|
+
// validate the resolved target stays contained rather than trusting that.
|
|
408
|
+
const root = resolve(CACHE_DIR);
|
|
409
|
+
const full = resolve(target);
|
|
410
|
+
if (full !== root && !full.startsWith(root + sep)) {
|
|
411
|
+
throw new Error(`rosetta-id ${JSON.stringify(rosettaId)} resolves outside cache dir ${CACHE_DIR}`);
|
|
412
|
+
}
|
|
413
|
+
return target;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Category/index pages (a directory's landing page, e.g.
|
|
418
|
+
* .../authentication-authorization-accounting/) are listed in sitemap.xml with a
|
|
419
|
+
* trailing slash and serve their Markdown at `index.md`, not `<slug>.md` — confirmed
|
|
420
|
+
* live (2026-07-07): `.../accounting.md` 404s, `.../accounting/index.md` is 200.
|
|
421
|
+
*/
|
|
422
|
+
export function markdownUrlFor(url: string): string {
|
|
423
|
+
return url.endsWith("/") ? `${url}index.md` : `${url}.md`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function fetchMarkdown(url: string): Promise<string> {
|
|
427
|
+
const mdUrl = markdownUrlFor(url);
|
|
428
|
+
const res = await fetch(mdUrl, { signal: AbortSignal.timeout(10_000) });
|
|
429
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${mdUrl}`);
|
|
430
|
+
return res.text();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function delay(ms: number): Promise<void> {
|
|
434
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Recursively list every cached .md file under CACHE_DIR, returning rosetta-ids. */
|
|
438
|
+
function listCachedRosettaIds(dir: string, prefix = ""): string[] {
|
|
439
|
+
if (!existsSync(dir)) return [];
|
|
440
|
+
const ids: string[] = [];
|
|
441
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
442
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
443
|
+
if (entry.isDirectory()) {
|
|
444
|
+
ids.push(...listCachedRosettaIds(join(dir, entry.name), rel));
|
|
445
|
+
} else if (entry.name.endsWith(".md")) {
|
|
446
|
+
ids.push(rel.slice(0, -".md".length));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return ids;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ── Count cross-check (B-0012 H8, V-docusaurus-docs-count) ──
|
|
453
|
+
|
|
454
|
+
/** Parse llms.txt link entries, scoped to the same /docs prose rule as isInScopeDocsUrl. */
|
|
455
|
+
export function parseLlmsTxtInScopeCount(llmsTxt: string): number {
|
|
456
|
+
const links = [...llmsTxt.matchAll(/\[[^\]]+\]\((https?:\/\/[^)]+\.mdx?)\)/g)].map((m) => m[1]);
|
|
457
|
+
return links.filter((u) => isInScopeDocsUrl(u)).length;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function checkCounts(extractedCount: number): Promise<boolean> {
|
|
461
|
+
try {
|
|
462
|
+
const res = await fetch(LLMS_TXT_URL, { signal: AbortSignal.timeout(10_000) });
|
|
463
|
+
// Don't parse an error page as if it were llms.txt — a non-2xx here would yield a
|
|
464
|
+
// bogus expected count (misleading mismatch, or a false match). Route it into the
|
|
465
|
+
// catch below so the cross-check is reported as skipped rather than wrong.
|
|
466
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${LLMS_TXT_URL}`);
|
|
467
|
+
const llmsTxt = await res.text();
|
|
468
|
+
const expected = parseLlmsTxtInScopeCount(llmsTxt);
|
|
469
|
+
const ok = expected === extractedCount;
|
|
470
|
+
console.log(`\nCount cross-check (V-docusaurus-docs-count${STRICT ? "" : ", non-blocking"}): llms.txt in-scope=${expected}, extracted=${extractedCount} — ${ok ? "MATCH" : "MISMATCH"}`);
|
|
471
|
+
return ok;
|
|
472
|
+
} catch (e) {
|
|
473
|
+
console.log(`\nCount cross-check skipped (fetch failed): ${e}`);
|
|
474
|
+
// Plain --check-counts (local/manual) stays soft: a network blip shouldn't fail
|
|
475
|
+
// a dev run. But --strict is release.yml's blocking use (V-docusaurus-docs-count) —
|
|
476
|
+
// there, a skipped cross-check must not silently read as a pass.
|
|
477
|
+
return !STRICT;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ── Main ──
|
|
482
|
+
|
|
483
|
+
async function main() {
|
|
484
|
+
console.log("Initializing database...");
|
|
485
|
+
initDb();
|
|
486
|
+
|
|
487
|
+
console.log(FROM_CACHE ? `Discovering pages from cache: ${CACHE_DIR}` : `Discovering pages from sitemap: ${SITEMAP_URL}`);
|
|
488
|
+
|
|
489
|
+
let rosettaIds: string[];
|
|
490
|
+
let urlByRosettaId: Map<string, string>;
|
|
491
|
+
|
|
492
|
+
if (FROM_CACHE) {
|
|
493
|
+
rosettaIds = listCachedRosettaIds(CACHE_DIR);
|
|
494
|
+
urlByRosettaId = new Map(rosettaIds.map((id) => [id, rosettaIdToUrl(id)]));
|
|
495
|
+
} else {
|
|
496
|
+
const sitemapUrls = (await loadSitemapUrls()).filter(isInScopeDocsUrl);
|
|
497
|
+
urlByRosettaId = new Map(sitemapUrls.map((u) => [deriveRosettaId(u), u]));
|
|
498
|
+
rosettaIds = [...urlByRosettaId.keys()];
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
rosettaIds.sort();
|
|
502
|
+
if (LIMIT) rosettaIds = rosettaIds.slice(0, LIMIT);
|
|
503
|
+
|
|
504
|
+
console.log(`Pages in scope: ${rosettaIds.length}`);
|
|
505
|
+
|
|
506
|
+
const parsedPages: ParsedPage[] = [];
|
|
507
|
+
let fetchErrors = 0;
|
|
508
|
+
|
|
509
|
+
for (const rosettaId of rosettaIds) {
|
|
510
|
+
const url = urlByRosettaId.get(rosettaId) ?? rosettaIdToUrl(rosettaId);
|
|
511
|
+
const cacheFile = cachePathFor(rosettaId);
|
|
512
|
+
let md: string;
|
|
513
|
+
|
|
514
|
+
if (FROM_CACHE) {
|
|
515
|
+
md = readFileSync(cacheFile, "utf-8");
|
|
516
|
+
} else {
|
|
517
|
+
try {
|
|
518
|
+
md = await fetchMarkdown(url);
|
|
519
|
+
mkdirSync(dirname(cacheFile), { recursive: true });
|
|
520
|
+
writeFileSync(cacheFile, md);
|
|
521
|
+
await delay(FETCH_DELAY_MS);
|
|
522
|
+
} catch (e) {
|
|
523
|
+
console.log(` ERROR: ${url}: ${e}`);
|
|
524
|
+
fetchErrors++;
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
parsedPages.push(parsePage(md, url));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
console.log(`Fetched/read: ${parsedPages.length}, errors: ${fetchErrors}`);
|
|
533
|
+
|
|
534
|
+
// Guard BEFORE the destructive rebuild below: if every fetch failed (network/sitemap
|
|
535
|
+
// outage) or the cache was empty, bail without wiping an existing, good DB. Deleting
|
|
536
|
+
// first and then discovering there's nothing to insert leaves downstream consumers an
|
|
537
|
+
// empty DB (Copilot/CodeRabbit review, PR #13).
|
|
538
|
+
if (parsedPages.length === 0) {
|
|
539
|
+
console.error(`\n::error::extract-docusaurus: 0 pages extracted. Check sitemap/network/cache-dir.`);
|
|
540
|
+
process.exit(1);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Idempotent rebuild — this extractor owns pages/sections/properties/callouts
|
|
544
|
+
// for the Docusaurus era the same way extract-html.ts owned them for Confluence;
|
|
545
|
+
// the two are not meant to populate the same DB together (MANUAL.md).
|
|
546
|
+
//
|
|
547
|
+
// Unlike extract-html.ts (which preserves stable explicit page ids via INSERT OR
|
|
548
|
+
// REPLACE), this extractor re-mints pages.id as fresh rowids each run, so any existing
|
|
549
|
+
// commands.page_id / schema_nodes.page_id would dangle or point at unrelated new rows
|
|
550
|
+
// after the wipe. NULL those links first so a STANDALONE run stays internally
|
|
551
|
+
// consistent; the pipeline's `link` step (link-commands.ts) and extract-schema
|
|
552
|
+
// re-establish them afterward. Both columns are nullable, so this is safe with FKs on.
|
|
553
|
+
db.run("UPDATE commands SET page_id = NULL;");
|
|
554
|
+
db.run("UPDATE schema_nodes SET page_id = NULL;");
|
|
555
|
+
db.run("DELETE FROM sections;");
|
|
556
|
+
db.run("DELETE FROM callouts;");
|
|
557
|
+
db.run("INSERT INTO callouts_fts(callouts_fts) VALUES('rebuild');");
|
|
558
|
+
db.run("DELETE FROM properties;");
|
|
559
|
+
db.run("INSERT INTO properties_fts(properties_fts) VALUES('rebuild');");
|
|
560
|
+
db.run("PRAGMA foreign_keys = OFF;");
|
|
561
|
+
db.run("DELETE FROM pages;");
|
|
562
|
+
db.run("PRAGMA foreign_keys = ON;");
|
|
563
|
+
db.run("INSERT INTO pages_fts(pages_fts) VALUES('rebuild');");
|
|
564
|
+
|
|
565
|
+
const insertPage = db.prepare(`
|
|
566
|
+
INSERT INTO pages
|
|
567
|
+
(rosetta_id, slug, title, path, depth, parent_id, url, text, code, code_lang,
|
|
568
|
+
author, last_updated, word_count, code_lines, html_file)
|
|
569
|
+
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, NULL, NULL, ?, ?, ?)
|
|
570
|
+
`);
|
|
571
|
+
const insertSection = db.prepare(`
|
|
572
|
+
INSERT INTO sections (page_id, heading, level, anchor_id, text, code, word_count, sort_order)
|
|
573
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
574
|
+
`);
|
|
575
|
+
const insertProperty = db.prepare(`
|
|
576
|
+
INSERT OR IGNORE INTO properties (page_id, name, type, default_val, description, section, sort_order)
|
|
577
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
578
|
+
`);
|
|
579
|
+
const insertCallout = db.prepare(`
|
|
580
|
+
INSERT INTO callouts (page_id, type, content, sort_order)
|
|
581
|
+
VALUES (?, ?, ?, ?)
|
|
582
|
+
`);
|
|
583
|
+
|
|
584
|
+
let totalSections = 0;
|
|
585
|
+
let totalProperties = 0;
|
|
586
|
+
let malformedProperties = 0;
|
|
587
|
+
let totalCallouts = 0;
|
|
588
|
+
|
|
589
|
+
const insertAll = db.transaction(() => {
|
|
590
|
+
for (const page of parsedPages) {
|
|
591
|
+
const cacheRelPath = cachePathFor(page.rosettaId).slice(PROJECT_ROOT.length + 1);
|
|
592
|
+
const result = insertPage.run(
|
|
593
|
+
page.rosettaId,
|
|
594
|
+
page.slug,
|
|
595
|
+
page.title,
|
|
596
|
+
page.path,
|
|
597
|
+
page.depth,
|
|
598
|
+
page.url,
|
|
599
|
+
page.text,
|
|
600
|
+
page.code,
|
|
601
|
+
page.codeLang,
|
|
602
|
+
page.wordCount,
|
|
603
|
+
page.codeLines,
|
|
604
|
+
cacheRelPath,
|
|
605
|
+
);
|
|
606
|
+
const pageId = Number(result.lastInsertRowid);
|
|
607
|
+
|
|
608
|
+
for (const s of page.sections) {
|
|
609
|
+
insertSection.run(pageId, s.heading, s.level, s.anchorId, s.text, s.code, s.wordCount, s.sortOrder);
|
|
610
|
+
totalSections++;
|
|
611
|
+
}
|
|
612
|
+
let propOrder = 0;
|
|
613
|
+
for (const p of page.properties) {
|
|
614
|
+
insertProperty.run(pageId, p.name, p.rawType, p.defaultVal, p.description, p.section, propOrder++);
|
|
615
|
+
totalProperties++;
|
|
616
|
+
if (p.malformedEmphasis) malformedProperties++;
|
|
617
|
+
}
|
|
618
|
+
for (const c of page.callouts) {
|
|
619
|
+
insertCallout.run(pageId, c.type, c.content, c.sortOrder);
|
|
620
|
+
totalCallouts++;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
insertAll();
|
|
625
|
+
|
|
626
|
+
console.log(`\nExtraction complete:`);
|
|
627
|
+
console.log(` Pages: ${parsedPages.length}`);
|
|
628
|
+
console.log(` Sections: ${totalSections}`);
|
|
629
|
+
console.log(` Properties: ${totalProperties} (${malformedProperties} malformed-emphasis)`);
|
|
630
|
+
console.log(` Callouts: ${totalCallouts}`);
|
|
631
|
+
|
|
632
|
+
if (CHECK_COUNTS) {
|
|
633
|
+
const ok = await checkCounts(parsedPages.length);
|
|
634
|
+
if (!ok && STRICT) process.exit(1);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (import.meta.main) {
|
|
639
|
+
main().catch((e) => {
|
|
640
|
+
console.error("Fatal:", e);
|
|
641
|
+
process.exit(1);
|
|
642
|
+
});
|
|
643
|
+
}
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
2
4
|
|
|
3
5
|
process.env.DB_PATH = ":memory:";
|
|
4
6
|
|
|
5
|
-
const { githubApiHeaders } = await import("./
|
|
7
|
+
const { githubApiHeaders } = await import("./github.ts");
|
|
8
|
+
const ROOT = join(import.meta.dirname, "..");
|
|
9
|
+
|
|
10
|
+
function readText(relPath: string): string {
|
|
11
|
+
return readFileSync(join(ROOT, relPath), "utf-8");
|
|
12
|
+
}
|
|
6
13
|
|
|
7
14
|
describe("extract-skills GitHub API headers", () => {
|
|
8
15
|
afterEach(() => {
|
|
@@ -36,4 +43,36 @@ describe("extract-skills GitHub API headers", () => {
|
|
|
36
43
|
Authorization: "Bearer gh-token",
|
|
37
44
|
});
|
|
38
45
|
});
|
|
39
|
-
|
|
46
|
+
|
|
47
|
+
test("supports GitHub raw content API accept header while preserving auth", () => {
|
|
48
|
+
process.env.GITHUB_TOKEN = "raw-token";
|
|
49
|
+
|
|
50
|
+
expect(githubApiHeaders("application/vnd.github.v3.raw")).toEqual({
|
|
51
|
+
Accept: "application/vnd.github.v3.raw",
|
|
52
|
+
Authorization: "Bearer raw-token",
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("extract-skills GitHub fetch shape", () => {
|
|
58
|
+
test("fetches skill files through the authenticated Contents API, not raw.githubusercontent.com", () => {
|
|
59
|
+
const src = readText("src/extract-skills.ts");
|
|
60
|
+
|
|
61
|
+
expect(src).not.toContain("raw.githubusercontent.com");
|
|
62
|
+
expect(src).toContain("/contents/");
|
|
63
|
+
expect(src).toContain('githubApiHeaders("application/vnd.github.v3.raw")');
|
|
64
|
+
expect(src).toContain("fetchGitHub");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("release.yml passes GITHUB_TOKEN to the skills extraction step", () => {
|
|
68
|
+
const yml = readText(".github/workflows/release.yml");
|
|
69
|
+
const skillsIdx = yml.indexOf("Extract agent skills from GitHub");
|
|
70
|
+
const linkIdx = yml.indexOf("Link commands to pages");
|
|
71
|
+
expect(skillsIdx).toBeGreaterThanOrEqual(0);
|
|
72
|
+
expect(linkIdx).toBeGreaterThan(skillsIdx);
|
|
73
|
+
|
|
74
|
+
const block = yml.slice(skillsIdx, linkIdx);
|
|
75
|
+
expect(block).toContain("GITHUB_TOKEN: $" + "{{ github.token }}");
|
|
76
|
+
expect(block).toContain("bun run src/extract-skills.ts");
|
|
77
|
+
});
|
|
78
|
+
});
|