sdtk-brain-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/assets/atlas/doc_atlas_viewer_template.html +4868 -0
- package/assets/atlas/vendor/mermaid.min.js +2029 -0
- package/bin/sdtk-brain.js +88 -0
- package/package.json +28 -0
- package/scripts/brain-smoke.test.js +19 -0
- package/scripts/sync-shared-assets.js +24 -0
- package/src/commands/enrich.js +55 -0
- package/src/commands/init.js +108 -0
- package/src/commands/lint.js +50 -0
- package/src/commands/open.js +59 -0
- package/src/commands/operations.js +357 -0
- package/src/commands/search.js +94 -0
- package/src/commands/status.js +56 -0
- package/src/lib/args.js +76 -0
- package/src/lib/browser-open.js +32 -0
- package/src/lib/errors.js +29 -0
- package/src/lib/wiki-build.js +1101 -0
- package/src/lib/wiki-compile.js +2108 -0
- package/src/lib/wiki-discover.js +271 -0
- package/src/lib/wiki-enrich.js +264 -0
- package/src/lib/wiki-extract.js +1313 -0
- package/src/lib/wiki-flags.js +97 -0
- package/src/lib/wiki-ingest.js +198 -0
- package/src/lib/wiki-lint.js +930 -0
- package/src/lib/wiki-paths.js +256 -0
- package/src/lib/wiki-score.js +64 -0
- package/src/lib/wiki-search.js +213 -0
- package/templates/VAULT_CLAUDE.md +39 -0
- package/templates/VAULT_README.md +18 -0
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const crypto = require("crypto");
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Constants
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
const ATLAS_STATE_VERSION = 6;
|
|
11
|
+
const WIKI_PAGE_SCHEMA_VERSION = 1;
|
|
12
|
+
const WIKI_PROVENANCE_SCHEMA_VERSION = 1;
|
|
13
|
+
|
|
14
|
+
const _ASSETS_DIR = path.join(__dirname, "..", "..", "assets", "atlas");
|
|
15
|
+
const MERMAID_VENDOR_PATH = path.join(_ASSETS_DIR, "vendor", "mermaid.min.js");
|
|
16
|
+
const MERMAID_ASSET_NAME = "mermaid.min.js";
|
|
17
|
+
const _VIEWER_TEMPLATE_PATH = path.join(_ASSETS_DIR, "doc_atlas_viewer_template.html");
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// _json_for_inline_script — parity with Python: compact, ensure_ascii, safe HTML
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
function _escapeNonAscii(str) {
|
|
23
|
+
return str.replace(/[^\x00-\x7F]/g, (c) => {
|
|
24
|
+
const code = c.codePointAt(0);
|
|
25
|
+
if (code <= 0xFFFF) {
|
|
26
|
+
return "\\u" + code.toString(16).padStart(4, "0");
|
|
27
|
+
}
|
|
28
|
+
// surrogate pair for > U+FFFF
|
|
29
|
+
const hi = Math.floor((code - 0x10000) / 0x400) + 0xD800;
|
|
30
|
+
const lo = ((code - 0x10000) % 0x400) + 0xDC00;
|
|
31
|
+
return "\\u" + hi.toString(16).padStart(4, "0") + "\\u" + lo.toString(16).padStart(4, "0");
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function _json_for_inline_script(value) {
|
|
36
|
+
const compact = JSON.stringify(value);
|
|
37
|
+
return _escapeNonAscii(compact)
|
|
38
|
+
.replace(/<\//g, "<\\/")
|
|
39
|
+
.replace(/<!--/g, "<\\!--");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Default exclude fragments
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
const DEFAULT_EXCLUDE_FRAGS = [
|
|
46
|
+
".git",
|
|
47
|
+
".sdtk/wiki",
|
|
48
|
+
".sdtk/atlas",
|
|
49
|
+
"node_modules",
|
|
50
|
+
".venv",
|
|
51
|
+
"venv",
|
|
52
|
+
"dist",
|
|
53
|
+
"build",
|
|
54
|
+
"coverage",
|
|
55
|
+
".next",
|
|
56
|
+
".turbo",
|
|
57
|
+
".cache",
|
|
58
|
+
"__pycache__",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Reference patterns — ported from Python re to JS RegExp
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
const RE_BK = /\bBK-(\d{3,})\b/g;
|
|
65
|
+
const RE_KNOWLEDGE_ID = /\b(KD|KT|KP|KA|KR|KRB|KF)-(\d{4})\b/g;
|
|
66
|
+
const RE_REPO_PATH = /(?:^|[\s`(\[])([a-zA-Z0-9_\-]+(?:\/[a-zA-Z0-9_\-. ]+)+\.(?:md|py|ps1|json|yaml|yml|html|txt))/gm;
|
|
67
|
+
const RE_WIKI_LINK = /\[\[([^\]]+)\]\]/g;
|
|
68
|
+
const RE_MARKDOWN_LINK = /(?<!!)\[[^\]]+\]\(([^)]+)\)/g;
|
|
69
|
+
const RE_SKILL_REF = /\b(sdtk-[a-z0-9][a-z0-9-]*)\b/g;
|
|
70
|
+
const RE_RELEASE_REF = /\b(?:sdtk-spec-kit@)?(0\.\d+\.\d+)\b/g;
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Family / role classifiers — verbatim from build_atlas.py
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
function classify_family(rel) {
|
|
76
|
+
const p = rel.replace(/\\/g, "/").toLowerCase();
|
|
77
|
+
const name = path.basename(rel).toLowerCase();
|
|
78
|
+
const is_guide_path = p.startsWith("guides/") || p.includes("/guides/");
|
|
79
|
+
if (p === "readme.md") return "root-readme";
|
|
80
|
+
if (name.includes("backlog")) return "backlog";
|
|
81
|
+
if (p.includes("skills")) return "skill";
|
|
82
|
+
if (p.includes("templates")) return "template";
|
|
83
|
+
if (p.includes("docs/database") || p.includes("database/")) return "database";
|
|
84
|
+
if (p.includes("docs/specs") || p.includes("specs/")) return "spec";
|
|
85
|
+
if (p.includes("docs/architecture") || p.includes("architecture/")) return "architecture";
|
|
86
|
+
if (p.includes("docs/api") || p.includes("api/")) return "api";
|
|
87
|
+
if (p.includes("docs/qa") || p.includes("qa/")) return "qa";
|
|
88
|
+
if (p.includes("docs/design") || p.includes("design/")) return "design";
|
|
89
|
+
if (p.includes("docs/dev") || p.includes("dev/")) return "dev";
|
|
90
|
+
if (p.includes("docs/product") || p.includes("product/")) return "product";
|
|
91
|
+
if (is_guide_path) return "guide";
|
|
92
|
+
if (p.includes("governance")) return "governance";
|
|
93
|
+
return "other-markdown";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function classify_role(rel) {
|
|
97
|
+
const p = rel.replace(/\\/g, "/").toLowerCase();
|
|
98
|
+
if (p.includes("governance")) return "governance";
|
|
99
|
+
if (p.includes("spec") || p.includes("architecture")) return "spec-artifact";
|
|
100
|
+
if (p.includes("skill")) return "skill";
|
|
101
|
+
return "other";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Family colors map — verbatim from build_atlas.py
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
const _FAMILY_COLORS = {
|
|
108
|
+
"governance": "#58a6ff",
|
|
109
|
+
"guide": "#14b8a6",
|
|
110
|
+
"backlog": "#d2a8ff",
|
|
111
|
+
"spec": "#f0883e",
|
|
112
|
+
"architecture": "#3fb950",
|
|
113
|
+
"database": "#a371f7",
|
|
114
|
+
"api": "#f778ba",
|
|
115
|
+
"qa": "#79c0ff",
|
|
116
|
+
"design": "#ffa657",
|
|
117
|
+
"dev": "#56d364",
|
|
118
|
+
"product": "#e3b341",
|
|
119
|
+
"skill": "#58a6ff",
|
|
120
|
+
"template": "#f0883e",
|
|
121
|
+
"root-readme": "#e3b341",
|
|
122
|
+
"other-markdown": "#8b949e",
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Scanner helpers
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
function _now_utc() {
|
|
129
|
+
return new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _write_text_lf(filePath, content) {
|
|
133
|
+
const normalised = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
134
|
+
fs.writeFileSync(filePath, normalised, { encoding: "utf8" });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function _assert_inside(base, target) {
|
|
138
|
+
const resolvedBase = fs.realpathSync(base);
|
|
139
|
+
let resolvedTarget;
|
|
140
|
+
try {
|
|
141
|
+
resolvedTarget = fs.realpathSync(target);
|
|
142
|
+
} catch (_) {
|
|
143
|
+
// target might not exist yet; resolve via path.resolve
|
|
144
|
+
resolvedTarget = path.resolve(target);
|
|
145
|
+
}
|
|
146
|
+
if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(resolvedBase + path.sep)) {
|
|
147
|
+
throw new Error(`Refusing to write outside SDTK-WIKI workspace: ${resolvedTarget}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function _normalise_exclude_fragment(frag) {
|
|
152
|
+
const norm = frag.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").toLowerCase();
|
|
153
|
+
return norm.split("/").filter((p) => p && p !== ".");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function _display_scan_path(filePath, root) {
|
|
157
|
+
const rel = path.relative(root, filePath);
|
|
158
|
+
return rel.split(path.sep).join("/");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function _match_exclude(filePath, root, exclude_frags) {
|
|
162
|
+
const rel = _display_scan_path(filePath, root).toLowerCase();
|
|
163
|
+
const rel_parts = rel.split("/").filter((p) => p && p !== ".");
|
|
164
|
+
|
|
165
|
+
for (const frag of exclude_frags) {
|
|
166
|
+
const frag_parts = _normalise_exclude_fragment(frag);
|
|
167
|
+
if (!frag_parts.length) continue;
|
|
168
|
+
if (frag_parts.length === 1) {
|
|
169
|
+
if (rel_parts.includes(frag_parts[0])) return frag;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
for (let idx = 0; idx <= rel_parts.length - frag_parts.length; idx++) {
|
|
173
|
+
const slice = rel_parts.slice(idx, idx + frag_parts.length);
|
|
174
|
+
if (slice.join("/") === frag_parts.join("/")) return frag;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// File scanner
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
function _rglob_md(dir) {
|
|
184
|
+
const results = [];
|
|
185
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
186
|
+
// Sort to match Python's sorted() on posix path
|
|
187
|
+
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
188
|
+
for (const entry of entries) {
|
|
189
|
+
const full = path.join(dir, entry.name);
|
|
190
|
+
if (entry.isDirectory()) {
|
|
191
|
+
results.push(..._rglob_md(full));
|
|
192
|
+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
|
|
193
|
+
results.push(full);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return results;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function collect_indexable_markdown_files(root, scan_roots, exclude_frags) {
|
|
200
|
+
const files = [];
|
|
201
|
+
const seen_paths = new Set();
|
|
202
|
+
const skipped_files = [];
|
|
203
|
+
let scanned_count = 0;
|
|
204
|
+
|
|
205
|
+
for (const scan_root of scan_roots) {
|
|
206
|
+
if (!fs.existsSync(scan_root)) {
|
|
207
|
+
process.stderr.write(`[atlas] Warning: scan root does not exist, skipping: ${scan_root}\n`);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let candidates;
|
|
212
|
+
const stat = fs.statSync(scan_root);
|
|
213
|
+
if (stat.isFile() && scan_root.toLowerCase().endsWith(".md")) {
|
|
214
|
+
candidates = [scan_root];
|
|
215
|
+
} else if (stat.isDirectory()) {
|
|
216
|
+
candidates = _rglob_md(scan_root);
|
|
217
|
+
} else {
|
|
218
|
+
candidates = [];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for (const md_file of candidates) {
|
|
222
|
+
scanned_count++;
|
|
223
|
+
const matched_exclude = _match_exclude(md_file, root, exclude_frags);
|
|
224
|
+
const display_path = _display_scan_path(md_file, root);
|
|
225
|
+
if (matched_exclude !== null) {
|
|
226
|
+
skipped_files.push({ path: display_path, reason: `exclude:${matched_exclude}` });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const rel = path.relative(root, md_file).split(path.sep).join("/");
|
|
230
|
+
if (seen_paths.has(rel)) {
|
|
231
|
+
skipped_files.push({ path: display_path, reason: "duplicate_scan_root" });
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
seen_paths.add(rel);
|
|
235
|
+
files.push(md_file);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
files.sort((a, b) => a.split(path.sep).join("/") < b.split(path.sep).join("/") ? -1 : 1);
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
files,
|
|
243
|
+
scanned_count,
|
|
244
|
+
indexed_count: files.length,
|
|
245
|
+
skipped_count: skipped_files.length,
|
|
246
|
+
skipped_files,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function list_indexable_markdown_files(root, scan_roots, exclude_frags) {
|
|
251
|
+
return collect_indexable_markdown_files(root, scan_roots, exclude_frags).files;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Document record parsers
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
function _extract_title(text) {
|
|
258
|
+
for (const line of text.split("\n")) {
|
|
259
|
+
const stripped = line.trim();
|
|
260
|
+
if (stripped.startsWith("# ")) {
|
|
261
|
+
return stripped.slice(2).trim();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return "";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function _extract_headings(text) {
|
|
268
|
+
const headings = [];
|
|
269
|
+
for (const line of text.split("\n")) {
|
|
270
|
+
const stripped = line.trim();
|
|
271
|
+
if (!stripped.startsWith("#")) continue;
|
|
272
|
+
let level = 0;
|
|
273
|
+
while (level < stripped.length && stripped[level] === "#") level++;
|
|
274
|
+
if (level >= 1 && level <= 6 && stripped.length > level && stripped[level] === " ") {
|
|
275
|
+
headings.push(stripped.slice(level + 1).trim());
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return headings;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function _parse_frontmatter(text) {
|
|
282
|
+
const lines = text.split("\n");
|
|
283
|
+
if (!lines.length || lines[0].trim() !== "---") return [{}, text];
|
|
284
|
+
|
|
285
|
+
const fields = {};
|
|
286
|
+
let current_list_key = null;
|
|
287
|
+
|
|
288
|
+
for (let idx = 1; idx < lines.length; idx++) {
|
|
289
|
+
const raw = lines[idx];
|
|
290
|
+
const stripped = raw.trim();
|
|
291
|
+
|
|
292
|
+
if (stripped === "---" || stripped === "...") {
|
|
293
|
+
// split("\n") produces trailing "" for a file ending with "\n",
|
|
294
|
+
// so join("\n") already yields the correct trailing newline — no extra append needed.
|
|
295
|
+
const body = lines.slice(idx + 1).join("\n");
|
|
296
|
+
return [fields, body];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!stripped) { current_list_key = null; continue; }
|
|
300
|
+
|
|
301
|
+
if (stripped.startsWith("- ") && current_list_key && Array.isArray(fields[current_list_key])) {
|
|
302
|
+
fields[current_list_key].push(stripped.slice(2).trim().replace(/^["']|["']$/g, ""));
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!raw.includes(":")) { current_list_key = null; continue; }
|
|
307
|
+
|
|
308
|
+
const colon = raw.indexOf(":");
|
|
309
|
+
const key = raw.slice(0, colon).trim();
|
|
310
|
+
const value = raw.slice(colon + 1).trim();
|
|
311
|
+
|
|
312
|
+
if (!key) { current_list_key = null; continue; }
|
|
313
|
+
|
|
314
|
+
if (!value) {
|
|
315
|
+
fields[key] = [];
|
|
316
|
+
current_list_key = key;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
321
|
+
const inner = value.slice(1, -1).trim();
|
|
322
|
+
if (inner) {
|
|
323
|
+
fields[key] = inner.split(",").map((p) => p.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
324
|
+
} else {
|
|
325
|
+
fields[key] = [];
|
|
326
|
+
}
|
|
327
|
+
current_list_key = null;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
fields[key] = value.replace(/^["']|["']$/g, "");
|
|
332
|
+
current_list_key = null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return [{}, text];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function _normalize_internal_ref(raw) {
|
|
339
|
+
let value = raw.trim();
|
|
340
|
+
if (!value) return "";
|
|
341
|
+
value = value.split("|")[0].trim();
|
|
342
|
+
value = value.split("#")[0].trim();
|
|
343
|
+
value = value.replace(/\\/g, "/");
|
|
344
|
+
while (value.startsWith("./")) value = value.slice(2);
|
|
345
|
+
if (value.startsWith("/")) value = value.slice(1);
|
|
346
|
+
return value.trim();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function _findAll(re, text) {
|
|
350
|
+
const results = [];
|
|
351
|
+
let m;
|
|
352
|
+
const pattern = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
|
|
353
|
+
pattern.lastIndex = 0;
|
|
354
|
+
while ((m = pattern.exec(text)) !== null) {
|
|
355
|
+
results.push(m);
|
|
356
|
+
}
|
|
357
|
+
return results;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function _extract_references(text) {
|
|
361
|
+
const issues_set = new Set();
|
|
362
|
+
for (const m of _findAll(RE_BK, text)) issues_set.add(`BK-${m[1]}`);
|
|
363
|
+
const issues = [...issues_set].sort();
|
|
364
|
+
|
|
365
|
+
const knowledge_set = new Set();
|
|
366
|
+
for (const m of _findAll(RE_KNOWLEDGE_ID, text)) knowledge_set.add(`${m[1]}-${m[2]}`);
|
|
367
|
+
const knowledge_ids = [...knowledge_set].sort();
|
|
368
|
+
|
|
369
|
+
const raw_paths = _findAll(RE_REPO_PATH, text).map((m) => m[1]);
|
|
370
|
+
const paths = [];
|
|
371
|
+
const seen = new Set();
|
|
372
|
+
for (const rp of raw_paths) {
|
|
373
|
+
const normalised = _normalize_internal_ref(rp);
|
|
374
|
+
if (normalised && !seen.has(normalised)) {
|
|
375
|
+
seen.add(normalised);
|
|
376
|
+
paths.push(normalised);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return [issues, knowledge_ids, paths];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function _extract_wiki_links(text) {
|
|
384
|
+
const links = [];
|
|
385
|
+
const seen = new Set();
|
|
386
|
+
for (const m of _findAll(RE_WIKI_LINK, text)) {
|
|
387
|
+
const normalised = _normalize_internal_ref(m[1]);
|
|
388
|
+
if (normalised && !seen.has(normalised)) {
|
|
389
|
+
seen.add(normalised);
|
|
390
|
+
links.push(normalised);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return links;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function _extract_markdown_links(text) {
|
|
397
|
+
const links = [];
|
|
398
|
+
const seen = new Set();
|
|
399
|
+
for (const m of _findAll(RE_MARKDOWN_LINK, text)) {
|
|
400
|
+
let target = m[1].trim().replace(/^<|>$/g, "");
|
|
401
|
+
const lower = target.toLowerCase();
|
|
402
|
+
if (!target || lower.startsWith("http://") || lower.startsWith("https://") ||
|
|
403
|
+
lower.startsWith("mailto:") || lower.startsWith("#") || target.includes("://")) {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (target.includes(' "')) target = target.split(' "')[0];
|
|
407
|
+
if (target.includes(" '")) target = target.split(" '")[0];
|
|
408
|
+
const normalised = _normalize_internal_ref(target);
|
|
409
|
+
if (normalised && !seen.has(normalised)) {
|
|
410
|
+
seen.add(normalised);
|
|
411
|
+
links.push(normalised);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return links;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function _extract_skill_refs(text, path_refs, wiki_links) {
|
|
418
|
+
const refs = new Set();
|
|
419
|
+
for (const m of _findAll(RE_SKILL_REF, text)) refs.add(m[1].toLowerCase());
|
|
420
|
+
for (const ref of [...path_refs, ...wiki_links]) {
|
|
421
|
+
const parts = ref.split("/").filter(Boolean);
|
|
422
|
+
for (const marker of ["skills", "skills-claude"]) {
|
|
423
|
+
if (parts.includes(marker)) {
|
|
424
|
+
const idx = parts.indexOf(marker);
|
|
425
|
+
if (idx + 1 < parts.length) refs.add(parts[idx + 1].toLowerCase());
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return [...refs].sort();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function _extract_template_refs(path_refs, wiki_links) {
|
|
433
|
+
const refs = new Set();
|
|
434
|
+
for (const ref of [...path_refs, ...wiki_links]) {
|
|
435
|
+
const norm = _normalize_internal_ref(ref);
|
|
436
|
+
if (("/" + norm).includes("/templates/")) refs.add(norm);
|
|
437
|
+
}
|
|
438
|
+
return [...refs].sort();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function _extract_release_refs(text) {
|
|
442
|
+
const refs = new Set();
|
|
443
|
+
for (const m of _findAll(RE_RELEASE_REF, text)) refs.add(m[1]);
|
|
444
|
+
return [...refs].sort();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function _compute_file_hash(filePath) {
|
|
448
|
+
const content = fs.readFileSync(filePath);
|
|
449
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function _parse_doc_record(md_file, root) {
|
|
453
|
+
const rel = _display_scan_path(md_file, root);
|
|
454
|
+
const text = fs.readFileSync(md_file, { encoding: "utf8" });
|
|
455
|
+
const [frontmatter_fields, body_text] = _parse_frontmatter(text);
|
|
456
|
+
const title = String(
|
|
457
|
+
frontmatter_fields["title"] || _extract_title(body_text) ||
|
|
458
|
+
path.basename(md_file, ".md").replace(/_/g, " ").replace(/-/g, " ")
|
|
459
|
+
);
|
|
460
|
+
const headings = _extract_headings(body_text);
|
|
461
|
+
const [issues, knowledge_ids, path_refs_raw] = _extract_references(text);
|
|
462
|
+
const wiki_links = _extract_wiki_links(text);
|
|
463
|
+
const markdown_links = _extract_markdown_links(text);
|
|
464
|
+
const path_refs = [...new Set([...path_refs_raw, ...markdown_links])].sort();
|
|
465
|
+
const family = classify_family(rel);
|
|
466
|
+
const role = classify_role(rel);
|
|
467
|
+
const skill_refs = _extract_skill_refs(text, path_refs, wiki_links);
|
|
468
|
+
const template_refs = _extract_template_refs(path_refs, wiki_links);
|
|
469
|
+
const release_refs = _extract_release_refs(text);
|
|
470
|
+
return {
|
|
471
|
+
id: rel,
|
|
472
|
+
path: rel,
|
|
473
|
+
title,
|
|
474
|
+
family,
|
|
475
|
+
role,
|
|
476
|
+
trust_zone: "medium",
|
|
477
|
+
body_markdown: body_text,
|
|
478
|
+
issues,
|
|
479
|
+
knowledge_ids,
|
|
480
|
+
headings,
|
|
481
|
+
frontmatter_fields,
|
|
482
|
+
skill_refs,
|
|
483
|
+
template_refs,
|
|
484
|
+
release_refs,
|
|
485
|
+
lane_refs: [],
|
|
486
|
+
wiki_links,
|
|
487
|
+
path_refs,
|
|
488
|
+
outgoing_paths: path_refs,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
// Incremental build state
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
function _empty_atlas_state() {
|
|
496
|
+
return { version: ATLAS_STATE_VERSION, documents: {} };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function _atlas_state_path(atlas_dir) {
|
|
500
|
+
return path.join(atlas_dir, "ATLAS_STATE.json");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function load_atlas_state(atlas_dir) {
|
|
504
|
+
const state_path = _atlas_state_path(atlas_dir);
|
|
505
|
+
if (!fs.existsSync(state_path)) return _empty_atlas_state();
|
|
506
|
+
try {
|
|
507
|
+
const data = JSON.parse(fs.readFileSync(state_path, "utf8"));
|
|
508
|
+
if (typeof data !== "object" || data === null) return _empty_atlas_state();
|
|
509
|
+
if (data.version !== ATLAS_STATE_VERSION) return _empty_atlas_state();
|
|
510
|
+
if (typeof data.documents !== "object" || data.documents === null) return _empty_atlas_state();
|
|
511
|
+
return { version: ATLAS_STATE_VERSION, generated: data.generated, documents: data.documents };
|
|
512
|
+
} catch (_) {
|
|
513
|
+
return _empty_atlas_state();
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function save_atlas_state(state, atlas_dir) {
|
|
518
|
+
fs.mkdirSync(atlas_dir, { recursive: true });
|
|
519
|
+
const state_path = _atlas_state_path(atlas_dir);
|
|
520
|
+
_write_text_lf(state_path, _escapeNonAscii(JSON.stringify(state, null, 2)));
|
|
521
|
+
return state_path;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function build_docs_incremental(root, atlas_dir, generated, scan_roots, exclude_frags) {
|
|
525
|
+
const prior_state = load_atlas_state(atlas_dir);
|
|
526
|
+
const prior_documents = prior_state.documents || {};
|
|
527
|
+
const scan_result = collect_indexable_markdown_files(root, scan_roots, exclude_frags);
|
|
528
|
+
const current_files = scan_result.files;
|
|
529
|
+
|
|
530
|
+
const current_rel_paths = {};
|
|
531
|
+
for (const md_file of current_files) {
|
|
532
|
+
const rel = path.relative(root, md_file).split(path.sep).join("/");
|
|
533
|
+
current_rel_paths[rel] = md_file;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const next_documents = {};
|
|
537
|
+
let reused_count = 0;
|
|
538
|
+
let reparsed_count = 0;
|
|
539
|
+
|
|
540
|
+
for (const [rel, md_file] of Object.entries(current_rel_paths)) {
|
|
541
|
+
const stats = fs.statSync(md_file);
|
|
542
|
+
const current_mtime = stats.mtimeMs * 1000000; // ns equivalent
|
|
543
|
+
const prior_record = prior_documents[rel];
|
|
544
|
+
const prior_doc = (prior_record && typeof prior_record === "object") ? prior_record.doc : null;
|
|
545
|
+
|
|
546
|
+
if (prior_record && typeof prior_record === "object" && prior_doc &&
|
|
547
|
+
prior_record.mtime === current_mtime) {
|
|
548
|
+
next_documents[rel] = prior_record;
|
|
549
|
+
reused_count++;
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const current_hash = _compute_file_hash(md_file);
|
|
554
|
+
if (prior_record && typeof prior_record === "object" && prior_doc &&
|
|
555
|
+
prior_record.hash === current_hash) {
|
|
556
|
+
next_documents[rel] = {
|
|
557
|
+
mtime: current_mtime,
|
|
558
|
+
hash: current_hash,
|
|
559
|
+
last_indexed: prior_record.last_indexed || generated,
|
|
560
|
+
doc: prior_doc,
|
|
561
|
+
};
|
|
562
|
+
reused_count++;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
next_documents[rel] = {
|
|
567
|
+
mtime: current_mtime,
|
|
568
|
+
hash: current_hash,
|
|
569
|
+
last_indexed: generated,
|
|
570
|
+
doc: _parse_doc_record(md_file, root),
|
|
571
|
+
};
|
|
572
|
+
reparsed_count++;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const removed_count = Object.keys(prior_documents).filter((k) => !(k in current_rel_paths)).length;
|
|
576
|
+
const docs = Object.values(next_documents)
|
|
577
|
+
.map((r) => r.doc)
|
|
578
|
+
.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
579
|
+
|
|
580
|
+
const next_state = {
|
|
581
|
+
version: ATLAS_STATE_VERSION,
|
|
582
|
+
generated,
|
|
583
|
+
documents: next_documents,
|
|
584
|
+
};
|
|
585
|
+
const build_stats = {
|
|
586
|
+
discovered_count: Object.keys(current_rel_paths).length,
|
|
587
|
+
scanned_count: scan_result.scanned_count,
|
|
588
|
+
indexed_count: Object.keys(current_rel_paths).length,
|
|
589
|
+
skipped_count: scan_result.skipped_count,
|
|
590
|
+
skipped_files: scan_result.skipped_files,
|
|
591
|
+
reused_count,
|
|
592
|
+
reparsed_count,
|
|
593
|
+
removed_count,
|
|
594
|
+
};
|
|
595
|
+
return [docs, next_state, build_stats];
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
// Graph builder
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
function _build_doc_alias_map(docs) {
|
|
602
|
+
const alias_map = {};
|
|
603
|
+
for (const doc of docs) {
|
|
604
|
+
const doc_id = doc.id;
|
|
605
|
+
const path_obj = path.parse(doc_id);
|
|
606
|
+
const aliases = new Set([
|
|
607
|
+
doc_id,
|
|
608
|
+
doc_id.toLowerCase(),
|
|
609
|
+
path_obj.base,
|
|
610
|
+
path_obj.base.toLowerCase(),
|
|
611
|
+
path_obj.name,
|
|
612
|
+
path_obj.name.toLowerCase(),
|
|
613
|
+
]);
|
|
614
|
+
if (doc_id.toLowerCase().endsWith(".md")) {
|
|
615
|
+
const no_ext = doc_id.slice(0, -3);
|
|
616
|
+
const no_ext_base = path.basename(no_ext);
|
|
617
|
+
aliases.add(no_ext);
|
|
618
|
+
aliases.add(no_ext.toLowerCase());
|
|
619
|
+
aliases.add(no_ext_base);
|
|
620
|
+
aliases.add(no_ext_base.toLowerCase());
|
|
621
|
+
}
|
|
622
|
+
for (const alias of aliases) {
|
|
623
|
+
if (!alias_map[alias]) alias_map[alias] = new Set();
|
|
624
|
+
alias_map[alias].add(doc_id);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return alias_map;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function _resolve_doc_reference(raw, alias_map) {
|
|
631
|
+
const normalised = _normalize_internal_ref(raw);
|
|
632
|
+
if (!normalised) return null;
|
|
633
|
+
const candidates = [normalised, normalised.toLowerCase()];
|
|
634
|
+
if (!normalised.toLowerCase().endsWith(".md")) {
|
|
635
|
+
candidates.push(`${normalised}.md`, `${normalised.toLowerCase()}.md`);
|
|
636
|
+
}
|
|
637
|
+
for (const candidate of candidates) {
|
|
638
|
+
const matches = alias_map[candidate];
|
|
639
|
+
if (matches && matches.size === 1) return [...matches][0];
|
|
640
|
+
}
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function build_graph(docs) {
|
|
645
|
+
const alias_map = _build_doc_alias_map(docs);
|
|
646
|
+
|
|
647
|
+
const nodes = docs.map((d) => ({
|
|
648
|
+
id: d.id,
|
|
649
|
+
title: d.title,
|
|
650
|
+
family: d.family,
|
|
651
|
+
role: d.role,
|
|
652
|
+
trust_zone: d.trust_zone || "medium",
|
|
653
|
+
}));
|
|
654
|
+
|
|
655
|
+
const edges = [];
|
|
656
|
+
|
|
657
|
+
for (const doc of docs) {
|
|
658
|
+
const src = doc.id;
|
|
659
|
+
|
|
660
|
+
for (const issue of (doc.issues || [])) {
|
|
661
|
+
edges.push({ source: src, target: issue, type: "references_issue", label: issue });
|
|
662
|
+
}
|
|
663
|
+
for (const kid of (doc.knowledge_ids || [])) {
|
|
664
|
+
edges.push({ source: src, target: kid, type: "references_knowledge_object", label: kid });
|
|
665
|
+
}
|
|
666
|
+
for (const rp of (doc.path_refs || doc.outgoing_paths || [])) {
|
|
667
|
+
const target = _resolve_doc_reference(rp, alias_map);
|
|
668
|
+
if (target) edges.push({ source: src, target, type: "references_path", label: rp });
|
|
669
|
+
}
|
|
670
|
+
for (const wiki_ref of (doc.wiki_links || [])) {
|
|
671
|
+
const target = _resolve_doc_reference(wiki_ref, alias_map);
|
|
672
|
+
if (target) edges.push({ source: src, target, type: "references_wiki_link", label: wiki_ref });
|
|
673
|
+
}
|
|
674
|
+
for (const skill_ref of (doc.skill_refs || [])) {
|
|
675
|
+
edges.push({ source: src, target: `__skill__${skill_ref}`, type: "references_skill", label: skill_ref });
|
|
676
|
+
}
|
|
677
|
+
for (const template_ref of (doc.template_refs || [])) {
|
|
678
|
+
edges.push({ source: src, target: `__template__${template_ref}`, type: "references_template", label: template_ref });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const family_groups = {};
|
|
683
|
+
for (const doc of docs) {
|
|
684
|
+
if (!family_groups[doc.family]) family_groups[doc.family] = [];
|
|
685
|
+
family_groups[doc.family].push(doc.id);
|
|
686
|
+
}
|
|
687
|
+
for (const [family, members] of Object.entries(family_groups)) {
|
|
688
|
+
if (members.length < 2) continue;
|
|
689
|
+
for (const mid of members) {
|
|
690
|
+
edges.push({ source: mid, target: `__family__${family}`, type: "same_family", label: family });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return { nodes, edges };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ---------------------------------------------------------------------------
|
|
698
|
+
// Summary markdown
|
|
699
|
+
// ---------------------------------------------------------------------------
|
|
700
|
+
function build_summary(docs, graph, generated, stats, root, scan_roots, exclude_frags) {
|
|
701
|
+
const family_counts = {};
|
|
702
|
+
for (const d of docs) family_counts[d.family] = (family_counts[d.family] || 0) + 1;
|
|
703
|
+
|
|
704
|
+
const edge_type_counts = {};
|
|
705
|
+
for (const e of graph.edges) edge_type_counts[e.type] = (edge_type_counts[e.type] || 0) + 1;
|
|
706
|
+
|
|
707
|
+
const lines = [
|
|
708
|
+
"# SDTK-WIKI Graph Summary",
|
|
709
|
+
"",
|
|
710
|
+
`Generated: ${generated}`,
|
|
711
|
+
`Project root: ${root}`,
|
|
712
|
+
"",
|
|
713
|
+
"## Document Counts",
|
|
714
|
+
"",
|
|
715
|
+
`Total documents indexed: ${docs.length}`,
|
|
716
|
+
"",
|
|
717
|
+
"| Family | Count |",
|
|
718
|
+
"|--------|-------|",
|
|
719
|
+
];
|
|
720
|
+
|
|
721
|
+
const sorted_families = Object.entries(family_counts).sort((a, b) => b[1] - a[1]);
|
|
722
|
+
for (const [fam, cnt] of sorted_families) lines.push(`| ${fam} | ${cnt} |`);
|
|
723
|
+
|
|
724
|
+
if (stats !== null && stats !== undefined) {
|
|
725
|
+
lines.push(
|
|
726
|
+
"", "## Incremental Build", "",
|
|
727
|
+
`Discovered markdown docs: ${stats.discovered_count}`,
|
|
728
|
+
`Scanned markdown candidates: ${stats.scanned_count !== undefined ? stats.scanned_count : stats.discovered_count}`,
|
|
729
|
+
`Indexed markdown docs: ${stats.indexed_count !== undefined ? stats.indexed_count : stats.discovered_count}`,
|
|
730
|
+
`Skipped markdown docs: ${stats.skipped_count || 0}`,
|
|
731
|
+
`Reused cached docs: ${stats.reused_count}`,
|
|
732
|
+
`Reparsed docs: ${stats.reparsed_count}`,
|
|
733
|
+
`Removed stale docs: ${stats.removed_count}`,
|
|
734
|
+
);
|
|
735
|
+
const skipped_files = stats.skipped_files || [];
|
|
736
|
+
if (skipped_files.length) {
|
|
737
|
+
lines.push("", "## Skipped Markdown Files", "", "| Path | Reason |", "|------|--------|");
|
|
738
|
+
for (const skipped of skipped_files) lines.push(`| ${skipped.path} | ${skipped.reason} |`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
lines.push("", "## Graph Summary", "",
|
|
743
|
+
`Total nodes: ${graph.nodes.length}`,
|
|
744
|
+
`Total edges: ${graph.edges.length}`,
|
|
745
|
+
"", "## Scan Roots", "");
|
|
746
|
+
for (const sr of scan_roots) lines.push(`- ${sr}`);
|
|
747
|
+
lines.push("", "## Exclusions Applied", "");
|
|
748
|
+
for (const frag of exclude_frags) lines.push(`- ${frag}`);
|
|
749
|
+
|
|
750
|
+
return lines.join("\n") + "\n";
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
// Viewer builder
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
function build_viewer(index, graph, generated) {
|
|
757
|
+
if (!fs.existsSync(_VIEWER_TEMPLATE_PATH)) {
|
|
758
|
+
throw new Error(`Viewer template not found: ${_VIEWER_TEMPLATE_PATH}`);
|
|
759
|
+
}
|
|
760
|
+
const index_json = _json_for_inline_script(index);
|
|
761
|
+
const graph_json = _json_for_inline_script(graph);
|
|
762
|
+
const family_colors_json = _json_for_inline_script(_FAMILY_COLORS);
|
|
763
|
+
const template = fs.readFileSync(_VIEWER_TEMPLATE_PATH, "utf8");
|
|
764
|
+
// Replacer functions, not plain strings: String.prototype.replace treats a
|
|
765
|
+
// string replacement's "$&", "$`", "$'", "$$", "$<n>" sequences as special
|
|
766
|
+
// patterns. Indexed doc content routinely contains "$" (shell docs, prices,
|
|
767
|
+
// template-literal examples), which previously spliced stray template/JSON
|
|
768
|
+
// fragments into the inline script and broke it (BK-317).
|
|
769
|
+
return template
|
|
770
|
+
.replace(/__ATLAS_GENERATED__/g, () => generated)
|
|
771
|
+
.replace(/__ATLAS_INDEX_JSON__/g, () => index_json)
|
|
772
|
+
.replace(/__ATLAS_GRAPH_JSON__/g, () => graph_json)
|
|
773
|
+
.replace(/__ATLAS_FAMILY_COLORS_JSON__/g, () => family_colors_json);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function copy_viewer_assets(atlas_dir) {
|
|
777
|
+
if (!fs.existsSync(MERMAID_VENDOR_PATH)) {
|
|
778
|
+
throw new Error(`Missing Mermaid runtime asset: ${MERMAID_VENDOR_PATH}`);
|
|
779
|
+
}
|
|
780
|
+
fs.mkdirSync(atlas_dir, { recursive: true });
|
|
781
|
+
const destination = path.join(atlas_dir, MERMAID_ASSET_NAME);
|
|
782
|
+
fs.copyFileSync(MERMAID_VENDOR_PATH, destination);
|
|
783
|
+
return [destination];
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ---------------------------------------------------------------------------
|
|
787
|
+
// Wiki pages + provenance
|
|
788
|
+
// ---------------------------------------------------------------------------
|
|
789
|
+
function _wiki_workspace_root(root) { return path.join(root, ".sdtk", "wiki"); }
|
|
790
|
+
function _wiki_pages_root(root) { return path.join(_wiki_workspace_root(root), "pages"); }
|
|
791
|
+
function _wiki_provenance_root(root) { return path.join(_wiki_workspace_root(root), "provenance"); }
|
|
792
|
+
|
|
793
|
+
function _stable_page_id(source_path) {
|
|
794
|
+
const norm = source_path.replace(/\\/g, "/");
|
|
795
|
+
const digest = crypto.createHash("sha256").update(norm, "utf8").digest("hex");
|
|
796
|
+
return `wiki:${digest.slice(0, 16)}`;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function _safe_slug(value) {
|
|
800
|
+
let slug = String(value).trim().toLowerCase();
|
|
801
|
+
slug = slug.replace(/[^a-z0-9]+/g, "-");
|
|
802
|
+
slug = slug.replace(/^-+|-+$/g, "");
|
|
803
|
+
return slug || "page";
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function _page_relative_path(doc) {
|
|
807
|
+
const source_path = String(doc.path).replace(/\\/g, "/");
|
|
808
|
+
const source_digest = crypto.createHash("sha256").update(source_path, "utf8").digest("hex").slice(0, 8);
|
|
809
|
+
const slug = _safe_slug(String(doc.title || path.parse(source_path).name));
|
|
810
|
+
const family = _safe_slug(String(doc.family || "other-markdown"));
|
|
811
|
+
return `.sdtk/wiki/pages/${family}/${slug}--${source_digest}.md`;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function _yaml_quote(value) {
|
|
815
|
+
const text = String(value);
|
|
816
|
+
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
817
|
+
return `"${escaped}"`;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function _render_generated_page(doc, page_id, source_hash, generated) {
|
|
821
|
+
const frontmatter = [
|
|
822
|
+
"---",
|
|
823
|
+
`schema_version: ${WIKI_PAGE_SCHEMA_VERSION}`,
|
|
824
|
+
'product: "SDTK-WIKI"',
|
|
825
|
+
'managed_by: "sdtk-wiki"',
|
|
826
|
+
`page_id: ${_yaml_quote(page_id)}`,
|
|
827
|
+
`source_path: ${_yaml_quote(doc.path)}`,
|
|
828
|
+
`source_hash: ${_yaml_quote(source_hash)}`,
|
|
829
|
+
`title: ${_yaml_quote(doc.title || "")}`,
|
|
830
|
+
`family: ${_yaml_quote(doc.family || "")}`,
|
|
831
|
+
`role: ${_yaml_quote(doc.role || "")}`,
|
|
832
|
+
`generated_at: ${_yaml_quote(generated)}`,
|
|
833
|
+
"---",
|
|
834
|
+
"",
|
|
835
|
+
];
|
|
836
|
+
let body = String(doc.body_markdown || "");
|
|
837
|
+
if (body && !body.endsWith("\n")) body += "\n";
|
|
838
|
+
return frontmatter.join("\n") + body;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function _prior_source_hashes(sources_path) {
|
|
842
|
+
if (!fs.existsSync(sources_path)) return {};
|
|
843
|
+
try {
|
|
844
|
+
const payload = JSON.parse(fs.readFileSync(sources_path, "utf8"));
|
|
845
|
+
const sources = payload.sources;
|
|
846
|
+
if (!Array.isArray(sources)) return {};
|
|
847
|
+
const hashes = {};
|
|
848
|
+
for (const record of sources) {
|
|
849
|
+
if (typeof record !== "object" || record === null) continue;
|
|
850
|
+
if (typeof record.sourcePath === "string" && typeof record.sourceHash === "string") {
|
|
851
|
+
hashes[record.sourcePath] = record.sourceHash;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return hashes;
|
|
855
|
+
} catch (_) {
|
|
856
|
+
return {};
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function _build_change_set(prior_hashes, current_hashes) {
|
|
861
|
+
const prior_paths = new Set(Object.keys(prior_hashes));
|
|
862
|
+
const current_paths = new Set(Object.keys(current_hashes));
|
|
863
|
+
const added = [...current_paths].filter((p) => !prior_paths.has(p)).sort();
|
|
864
|
+
const removed = [...prior_paths].filter((p) => !current_paths.has(p)).sort();
|
|
865
|
+
const changed = [...current_paths].filter(
|
|
866
|
+
(p) => prior_paths.has(p) && prior_hashes[p] !== current_hashes[p]
|
|
867
|
+
).sort();
|
|
868
|
+
const unchanged = [...current_paths].filter(
|
|
869
|
+
(p) => prior_paths.has(p) && prior_hashes[p] === current_hashes[p]
|
|
870
|
+
).sort();
|
|
871
|
+
return { added, changed, unchanged, removed };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function write_wiki_pages_and_provenance(docs, state, root, generated, scan_roots) {
|
|
875
|
+
const workspace_root = _wiki_workspace_root(root);
|
|
876
|
+
const pages_root = _wiki_pages_root(root);
|
|
877
|
+
const provenance_root = _wiki_provenance_root(root);
|
|
878
|
+
const sources_path = path.join(provenance_root, "sources.json");
|
|
879
|
+
const changes_path = path.join(provenance_root, "changes.json");
|
|
880
|
+
|
|
881
|
+
fs.mkdirSync(pages_root, { recursive: true });
|
|
882
|
+
fs.mkdirSync(provenance_root, { recursive: true });
|
|
883
|
+
|
|
884
|
+
const prior_hashes = _prior_source_hashes(sources_path);
|
|
885
|
+
const state_docs = (state && typeof state.documents === "object") ? state.documents : {};
|
|
886
|
+
const provenance_records = [];
|
|
887
|
+
const index_rows = [];
|
|
888
|
+
const current_hashes = {};
|
|
889
|
+
|
|
890
|
+
const sorted_docs = [...docs].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
891
|
+
|
|
892
|
+
for (const doc of sorted_docs) {
|
|
893
|
+
const source_path = String(doc.path).replace(/\\/g, "/");
|
|
894
|
+
const state_record = (state_docs && typeof state_docs === "object") ? state_docs[source_path] : null;
|
|
895
|
+
let source_hash = (state_record && typeof state_record.hash === "string") ? state_record.hash : null;
|
|
896
|
+
if (!source_hash) {
|
|
897
|
+
source_hash = crypto.createHash("sha256").update(source_path, "utf8").digest("hex");
|
|
898
|
+
}
|
|
899
|
+
const page_id = _stable_page_id(source_path);
|
|
900
|
+
const page_rel = _page_relative_path(doc);
|
|
901
|
+
const page_path = path.join(root, page_rel);
|
|
902
|
+
|
|
903
|
+
_assert_inside(workspace_root, page_path);
|
|
904
|
+
fs.mkdirSync(path.dirname(page_path), { recursive: true });
|
|
905
|
+
_write_text_lf(page_path, _render_generated_page(doc, page_id, source_hash, generated));
|
|
906
|
+
|
|
907
|
+
current_hashes[source_path] = source_hash;
|
|
908
|
+
provenance_records.push({
|
|
909
|
+
pageId: page_id,
|
|
910
|
+
sourcePath: source_path,
|
|
911
|
+
sourceHash: source_hash,
|
|
912
|
+
pagePath: page_rel,
|
|
913
|
+
graphNodeId: doc.id,
|
|
914
|
+
title: doc.title || "",
|
|
915
|
+
family: doc.family || "",
|
|
916
|
+
role: doc.role || "",
|
|
917
|
+
frontmatter: doc.frontmatter_fields || {},
|
|
918
|
+
headings: doc.headings || [],
|
|
919
|
+
issues: doc.issues || [],
|
|
920
|
+
knowledgeIds: doc.knowledge_ids || [],
|
|
921
|
+
pathRefs: doc.path_refs || [],
|
|
922
|
+
wikiLinks: doc.wiki_links || [],
|
|
923
|
+
});
|
|
924
|
+
index_rows.push([doc.title || source_path, source_path, page_rel, page_id]);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const index_lines = [
|
|
928
|
+
"# SDTK-WIKI Page Index",
|
|
929
|
+
"",
|
|
930
|
+
`Generated: ${generated}`,
|
|
931
|
+
"",
|
|
932
|
+
"| Title | Source | Page | Page ID |",
|
|
933
|
+
"|---|---|---|---|",
|
|
934
|
+
];
|
|
935
|
+
const sorted_rows = [...index_rows].sort((a, b) => a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0);
|
|
936
|
+
for (const [title, source_path, page_rel, page_id] of sorted_rows) {
|
|
937
|
+
index_lines.push(`| ${title} | \`${source_path}\` | \`${page_rel}\` | \`${page_id}\` |`);
|
|
938
|
+
}
|
|
939
|
+
_write_text_lf(path.join(pages_root, "_index.md"), index_lines.join("\n") + "\n");
|
|
940
|
+
|
|
941
|
+
const source_payload = {
|
|
942
|
+
schemaVersion: WIKI_PROVENANCE_SCHEMA_VERSION,
|
|
943
|
+
product: "SDTK-WIKI",
|
|
944
|
+
generatedAt: generated,
|
|
945
|
+
projectRoot: root,
|
|
946
|
+
scanRoots: scan_roots,
|
|
947
|
+
sourceCount: provenance_records.length,
|
|
948
|
+
sources: provenance_records,
|
|
949
|
+
};
|
|
950
|
+
_write_text_lf(sources_path, _escapeNonAscii(JSON.stringify(source_payload, null, 2)) + "\n");
|
|
951
|
+
|
|
952
|
+
const change_set = _build_change_set(prior_hashes, current_hashes);
|
|
953
|
+
const change_payload = {
|
|
954
|
+
schemaVersion: WIKI_PROVENANCE_SCHEMA_VERSION,
|
|
955
|
+
product: "SDTK-WIKI",
|
|
956
|
+
generatedAt: generated,
|
|
957
|
+
...change_set,
|
|
958
|
+
};
|
|
959
|
+
_write_text_lf(changes_path, _escapeNonAscii(JSON.stringify(change_payload, null, 2)) + "\n");
|
|
960
|
+
|
|
961
|
+
return {
|
|
962
|
+
page_count: provenance_records.length,
|
|
963
|
+
pages_root: pages_root,
|
|
964
|
+
page_index_path: path.join(pages_root, "_index.md"),
|
|
965
|
+
provenance_path: sources_path,
|
|
966
|
+
changes_path,
|
|
967
|
+
changes: change_set,
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// ---------------------------------------------------------------------------
|
|
972
|
+
// Top-level buildAtlas
|
|
973
|
+
// ---------------------------------------------------------------------------
|
|
974
|
+
async function buildAtlas({ projectRoot, outputDir, scanRoots, excludes, verbose } = {}) {
|
|
975
|
+
const generated = _now_utc();
|
|
976
|
+
const root = path.resolve(projectRoot);
|
|
977
|
+
const atlas_dir = path.resolve(outputDir);
|
|
978
|
+
const frags = (excludes != null) ? excludes : DEFAULT_EXCLUDE_FRAGS;
|
|
979
|
+
const roots = (scanRoots && scanRoots.length) ? scanRoots.map((r) => path.resolve(r)) : [root];
|
|
980
|
+
|
|
981
|
+
process.stdout.write(`[atlas] Project root: ${root}\n`);
|
|
982
|
+
process.stdout.write(`[atlas] Output dir: ${atlas_dir}\n`);
|
|
983
|
+
process.stdout.write(`[atlas] Scan roots: ${JSON.stringify(roots)}\n`);
|
|
984
|
+
|
|
985
|
+
fs.mkdirSync(atlas_dir, { recursive: true });
|
|
986
|
+
|
|
987
|
+
process.stdout.write("[atlas] Scanning markdown files...\n");
|
|
988
|
+
const [docs, state, stats] = build_docs_incremental(root, atlas_dir, generated, roots, frags);
|
|
989
|
+
|
|
990
|
+
process.stdout.write(`[atlas] Indexed ${docs.length} documents.\n`);
|
|
991
|
+
process.stdout.write(
|
|
992
|
+
`[atlas] Scan coverage: scanned ${stats.scanned_count !== undefined ? stats.scanned_count : docs.length}, ` +
|
|
993
|
+
`indexed ${stats.indexed_count !== undefined ? stats.indexed_count : docs.length}, ` +
|
|
994
|
+
`skipped ${stats.skipped_count || 0}.\n`
|
|
995
|
+
);
|
|
996
|
+
if (verbose) {
|
|
997
|
+
process.stdout.write(
|
|
998
|
+
`[atlas] Incremental build: reused ${stats.reused_count} cached, ` +
|
|
999
|
+
`reparsed ${stats.reparsed_count}, removed ${stats.removed_count}.\n`
|
|
1000
|
+
);
|
|
1001
|
+
for (const skipped of (stats.skipped_files || [])) {
|
|
1002
|
+
process.stdout.write(`[atlas] Skipped markdown: ${skipped.path} (${skipped.reason})\n`);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
process.stdout.write("[atlas] Building graph...\n");
|
|
1007
|
+
const graph = build_graph(docs);
|
|
1008
|
+
process.stdout.write(`[atlas] Graph: ${graph.nodes.length} nodes, ${graph.edges.length} edges.\n`);
|
|
1009
|
+
|
|
1010
|
+
process.stdout.write("[atlas] Writing wiki pages and provenance...\n");
|
|
1011
|
+
const wiki_result = write_wiki_pages_and_provenance(docs, state, root, generated, roots);
|
|
1012
|
+
process.stdout.write(`[atlas] Wiki pages: ${wiki_result.page_count}\n`);
|
|
1013
|
+
|
|
1014
|
+
const index_data = {
|
|
1015
|
+
generated,
|
|
1016
|
+
count: docs.length,
|
|
1017
|
+
documents: docs,
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
save_atlas_state(state, atlas_dir);
|
|
1021
|
+
|
|
1022
|
+
const index_path = path.join(atlas_dir, "SDTK_DOC_INDEX.json");
|
|
1023
|
+
_write_text_lf(index_path, _escapeNonAscii(JSON.stringify(index_data, null, 2)));
|
|
1024
|
+
|
|
1025
|
+
const graph_out = {
|
|
1026
|
+
generated,
|
|
1027
|
+
node_count: graph.nodes.length,
|
|
1028
|
+
edge_count: graph.edges.length,
|
|
1029
|
+
nodes: graph.nodes,
|
|
1030
|
+
edges: graph.edges,
|
|
1031
|
+
};
|
|
1032
|
+
const graph_path = path.join(atlas_dir, "SDTK_DOC_GRAPH.json");
|
|
1033
|
+
_write_text_lf(graph_path, _escapeNonAscii(JSON.stringify(graph_out, null, 2)));
|
|
1034
|
+
|
|
1035
|
+
const summary_text = build_summary(docs, graph, generated, stats, root, roots, frags);
|
|
1036
|
+
const summary_path = path.join(atlas_dir, "SDTK_DOC_ATLAS_SUMMARY.md");
|
|
1037
|
+
_write_text_lf(summary_path, summary_text);
|
|
1038
|
+
|
|
1039
|
+
const viewer_html = build_viewer(index_data, graph_out, generated);
|
|
1040
|
+
const viewer_path = path.join(atlas_dir, "viewer.html");
|
|
1041
|
+
_write_text_lf(viewer_path, viewer_html);
|
|
1042
|
+
|
|
1043
|
+
const copied_assets = copy_viewer_assets(atlas_dir);
|
|
1044
|
+
if (verbose) {
|
|
1045
|
+
for (const asset_path of copied_assets) {
|
|
1046
|
+
process.stdout.write(`[atlas] Wrote asset: ${path.basename(asset_path)}\n`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
process.stdout.write(`[atlas] Done. Output: ${atlas_dir}\n`);
|
|
1051
|
+
|
|
1052
|
+
const result = {
|
|
1053
|
+
generated,
|
|
1054
|
+
doc_count: docs.length,
|
|
1055
|
+
node_count: graph.nodes.length,
|
|
1056
|
+
edge_count: graph.edges.length,
|
|
1057
|
+
stats,
|
|
1058
|
+
atlas_dir,
|
|
1059
|
+
page_count: wiki_result.page_count,
|
|
1060
|
+
pages_root: wiki_result.pages_root,
|
|
1061
|
+
page_index_path: wiki_result.page_index_path,
|
|
1062
|
+
provenance_path: wiki_result.provenance_path,
|
|
1063
|
+
changes_path: wiki_result.changes_path,
|
|
1064
|
+
changes: wiki_result.changes,
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
process.stdout.write(`[atlas:result] ${JSON.stringify(result)}\n`);
|
|
1068
|
+
return result;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
module.exports = {
|
|
1072
|
+
buildAtlas,
|
|
1073
|
+
// exported for tests
|
|
1074
|
+
collect_indexable_markdown_files,
|
|
1075
|
+
list_indexable_markdown_files,
|
|
1076
|
+
classify_family,
|
|
1077
|
+
classify_role,
|
|
1078
|
+
_parse_doc_record,
|
|
1079
|
+
_extract_title,
|
|
1080
|
+
_extract_headings,
|
|
1081
|
+
_parse_frontmatter,
|
|
1082
|
+
_normalize_internal_ref,
|
|
1083
|
+
_extract_references,
|
|
1084
|
+
_extract_wiki_links,
|
|
1085
|
+
_extract_markdown_links,
|
|
1086
|
+
_extract_skill_refs,
|
|
1087
|
+
_extract_template_refs,
|
|
1088
|
+
_extract_release_refs,
|
|
1089
|
+
build_graph,
|
|
1090
|
+
build_summary,
|
|
1091
|
+
build_viewer,
|
|
1092
|
+
write_wiki_pages_and_provenance,
|
|
1093
|
+
load_atlas_state,
|
|
1094
|
+
save_atlas_state,
|
|
1095
|
+
DEFAULT_EXCLUDE_FRAGS,
|
|
1096
|
+
ATLAS_STATE_VERSION,
|
|
1097
|
+
_json_for_inline_script,
|
|
1098
|
+
_stable_page_id,
|
|
1099
|
+
_safe_slug,
|
|
1100
|
+
_page_relative_path,
|
|
1101
|
+
};
|