@chappibunny/repolens 1.12.2 → 1.13.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 (53) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +25 -36
  3. package/bin/repolens.js +21 -1
  4. package/package.json +20 -11
  5. package/src/ai/provider.js +1 -2
  6. package/src/analyzers/monorepo-detector.js +8 -20
  7. package/src/cli-args.js +60 -0
  8. package/src/cli.js +80 -100
  9. package/src/core/config-schema.js +47 -1
  10. package/src/core/config.js +5 -2
  11. package/src/core/diff.js +43 -20
  12. package/src/core/scan.js +24 -4
  13. package/src/delivery/comment.js +5 -2
  14. package/src/docs/write-doc-set.js +9 -7
  15. package/src/doctor.js +16 -3
  16. package/src/help.js +54 -0
  17. package/src/index.js +13 -0
  18. package/src/init.js +99 -62
  19. package/src/integrations/discord.js +16 -2
  20. package/src/migrate.js +2 -2
  21. package/src/publishers/confluence.js +17 -3
  22. package/src/publishers/github-wiki.js +61 -32
  23. package/src/publishers/index.js +24 -9
  24. package/src/publishers/markdown.js +3 -2
  25. package/src/publishers/notion.js +10 -4
  26. package/src/publishers/obsidian.js +716 -0
  27. package/src/utils/branch.js +37 -42
  28. package/src/utils/github-repo.js +8 -0
  29. package/src/utils/interactive.js +20 -0
  30. package/src/utils/logger.js +5 -1
  31. package/src/utils/retry.js +30 -6
  32. package/src/utils/safe-path.js +50 -0
  33. package/src/utils/secrets.js +3 -1
  34. package/src/utils/telemetry.js +34 -6
  35. package/src/utils/update-check.js +23 -6
  36. package/src/utils/validate.js +70 -1
  37. package/src/utils/yaml.js +27 -0
  38. package/src/watch.js +1 -1
  39. package/docs/AI.md +0 -329
  40. package/docs/ARCHITECTURE.md +0 -145
  41. package/docs/CONFIGURATION.md +0 -121
  42. package/docs/DEVELOPMENT.md +0 -79
  43. package/docs/ENVIRONMENT.md +0 -79
  44. package/docs/INSTALLATION.md +0 -45
  45. package/docs/KNOWN_ISSUES.md +0 -309
  46. package/docs/MIGRATION.md +0 -311
  47. package/docs/ONBOARDING.md +0 -382
  48. package/docs/PRODUCTION_CHECKLIST.md +0 -470
  49. package/docs/RELEASE.md +0 -76
  50. package/docs/ROADMAP.md +0 -151
  51. package/docs/STABILITY.md +0 -265
  52. package/docs/TELEMETRY.md +0 -166
  53. package/docs/TROUBLESHOOTING.md +0 -377
@@ -0,0 +1,716 @@
1
+ // Obsidian vault publisher for RepoLens.
2
+ //
3
+ // Writes generated notes into a folder inside a local Obsidian vault.
4
+ // Notes use YAML frontmatter and wikilinks. Updates are in-place and
5
+ // idempotent. Every path is contained inside the vault. No network.
6
+ //
7
+ // Config (.repolens.yml), all fields optional except vault at publish time:
8
+ // obsidian:
9
+ // vault: "/path/to/vault" # absolute, or relative to the repo
10
+ // folder: "RepoLens/my-app" # relative directory inside the vault
11
+ // branches: [main] # optional branch filter (enforced by the orchestrator)
12
+ // index: true # map-of-content note (default: true)
13
+ // frontmatter: true # YAML properties (default: true)
14
+ // wikilinks: true # rewrite markdown links (default: true)
15
+
16
+ import fs from "node:fs/promises";
17
+ import path from "node:path";
18
+ import { info, warn } from "../utils/logger.js";
19
+ import { getCurrentBranch } from "../utils/branch.js";
20
+ import { isInsideRoot, safeFileName } from "../utils/safe-path.js";
21
+
22
+ const INDEX_NOTE = "RepoLens";
23
+ const MANIFEST_FILE = ".repolens-obsidian.json";
24
+
25
+ const PAGE_ORDER = [
26
+ "executive_summary",
27
+ "system_overview",
28
+ "business_domains",
29
+ "architecture_overview",
30
+ "module_catalog",
31
+ "route_map",
32
+ "api_surface",
33
+ "data_flows",
34
+ "change_impact",
35
+ "system_map",
36
+ "developer_onboarding",
37
+ "graphql_schema",
38
+ "type_graph",
39
+ "dependency_graph",
40
+ "architecture_drift",
41
+ "arch_diff",
42
+ "security_hotspots",
43
+ "code_health",
44
+ ];
45
+
46
+ const PAGE_TITLES = {
47
+ executive_summary: "Executive Summary",
48
+ system_overview: "System Overview",
49
+ business_domains: "Business Domains",
50
+ architecture_overview: "Architecture Overview",
51
+ module_catalog: "Module Catalog",
52
+ route_map: "Route Map",
53
+ api_surface: "API Surface",
54
+ data_flows: "Data Flows",
55
+ change_impact: "Change Impact",
56
+ system_map: "System Map",
57
+ developer_onboarding: "Developer Onboarding",
58
+ graphql_schema: "GraphQL Schema",
59
+ type_graph: "Type Graph",
60
+ dependency_graph: "Dependency Graph",
61
+ architecture_drift: "Architecture Drift",
62
+ arch_diff: "Architecture Diff",
63
+ security_hotspots: "Security Hotspots",
64
+ code_health: "Code Health",
65
+ };
66
+
67
+ const PAGE_DESCRIPTIONS = {
68
+ executive_summary: "High-level project summary for non-technical readers.",
69
+ system_overview: "Snapshot of stack, scale, structure, and detected capabilities.",
70
+ business_domains: "Functional areas inferred from the repository.",
71
+ architecture_overview: "Layered technical view of the system.",
72
+ module_catalog: "Inventory of modules and responsibilities.",
73
+ route_map: "Frontend routes and page structure.",
74
+ api_surface: "Detected endpoints, handlers, and API structure.",
75
+ data_flows: "How information moves through the system.",
76
+ change_impact: "Architecture changes and likely downstream effects.",
77
+ system_map: "Visual map of system relationships.",
78
+ developer_onboarding: "What a new engineer needs to know first.",
79
+ graphql_schema: "GraphQL types, operations, and schema structure.",
80
+ type_graph: "Type-level relationships across the codebase.",
81
+ dependency_graph: "Module and package dependency relationships.",
82
+ architecture_drift: "Drift between intended and current architecture.",
83
+ arch_diff: "Architecture-level diff across revisions.",
84
+ security_hotspots: "Security anti-patterns with CWE classification.",
85
+ code_health: "Complexity and unified health scores.",
86
+ };
87
+
88
+ const PAGE_TAGS = {
89
+ executive_summary: "stakeholders",
90
+ system_overview: "overview",
91
+ business_domains: "stakeholders",
92
+ architecture_overview: "engineers",
93
+ module_catalog: "engineers",
94
+ route_map: "engineers",
95
+ api_surface: "engineers",
96
+ data_flows: "stakeholders",
97
+ change_impact: "engineers",
98
+ system_map: "overview",
99
+ developer_onboarding: "onboarding",
100
+ graphql_schema: "engineers",
101
+ type_graph: "engineers",
102
+ dependency_graph: "engineers",
103
+ architecture_drift: "engineers",
104
+ arch_diff: "engineers",
105
+ security_hotspots: "security",
106
+ code_health: "engineers",
107
+ };
108
+
109
+ const AUDIENCE_GROUPS = [
110
+ {
111
+ title: "For Stakeholders",
112
+ keys: ["executive_summary", "business_domains", "data_flows"],
113
+ },
114
+ {
115
+ title: "For Engineers",
116
+ keys: [
117
+ "architecture_overview", "module_catalog", "api_surface",
118
+ "route_map", "system_map", "graphql_schema", "type_graph",
119
+ "dependency_graph", "architecture_drift", "security_hotspots", "code_health",
120
+ ],
121
+ },
122
+ {
123
+ title: "For New Contributors",
124
+ keys: ["developer_onboarding", "system_overview"],
125
+ },
126
+ {
127
+ title: "Change Tracking",
128
+ keys: ["change_impact", "arch_diff"],
129
+ },
130
+ ];
131
+
132
+ function displayTitle(key) {
133
+ return PAGE_TITLES[key] || String(key).replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
134
+ }
135
+
136
+ /**
137
+ * Reduce a note title to a single path segment Obsidian and Windows can store.
138
+ */
139
+ export function sanitizeNoteName(name) {
140
+ const cleaned = String(name ?? "")
141
+ .replace(/[<>:"|?*\u0000-\u001f\\/]/g, " ")
142
+ .replace(/\.\.+/g, " ")
143
+ .replace(/\s+/g, " ")
144
+ .trim()
145
+ .replace(/[. ]+$/g, "")
146
+ .replace(/^\.+/, "");
147
+ if (!cleaned || cleaned === "." || cleaned === "..") return "Note";
148
+ return cleaned;
149
+ }
150
+
151
+ function noteStemForKey(key, useWikilinks) {
152
+ if (!useWikilinks) {
153
+ const file = safeFileName(`${key}.md`, "document.md");
154
+ return sanitizeNoteName(file.replace(/\.md$/i, ""));
155
+ }
156
+ return sanitizeNoteName(displayTitle(key));
157
+ }
158
+
159
+ function allocateNotes(keys, useWikilinks) {
160
+ const used = new Set([INDEX_NOTE]);
161
+ const notes = [];
162
+ for (const key of keys) {
163
+ const base = noteStemForKey(key, useWikilinks);
164
+ let name = base;
165
+ let n = 2;
166
+ while (used.has(name)) {
167
+ const suffix = n === 2 ? key : `${key} ${n}`;
168
+ name = sanitizeNoteName(`${base} (${suffix})`);
169
+ n += 1;
170
+ if (n > 50) {
171
+ throw new Error(`Could not allocate a unique Obsidian note name for ${key}`);
172
+ }
173
+ }
174
+ used.add(name);
175
+ notes.push({ key, name, fileName: `${name}.md` });
176
+ }
177
+ return notes;
178
+ }
179
+
180
+ function yamlQuote(value) {
181
+ return JSON.stringify(String(value));
182
+ }
183
+
184
+ function buildFrontmatter(fields) {
185
+ const lines = ["---"];
186
+ for (const [key, value] of Object.entries(fields)) {
187
+ if (value == null) continue;
188
+ if (Array.isArray(value)) {
189
+ const items = value.filter((item) => item != null && String(item).length > 0);
190
+ if (items.length === 0) continue;
191
+ lines.push(`${key}:`);
192
+ for (const item of items) lines.push(` - ${yamlQuote(item)}`);
193
+ } else if (typeof value === "boolean" || typeof value === "number") {
194
+ lines.push(`${key}: ${value}`);
195
+ } else {
196
+ lines.push(`${key}: ${yamlQuote(value)}`);
197
+ }
198
+ }
199
+ lines.push("---", "");
200
+ return lines.join("\n");
201
+ }
202
+
203
+ function ensureTrailingNewline(text) {
204
+ return text.endsWith("\n") ? text : `${text}\n`;
205
+ }
206
+
207
+ const MAX_LINK_LABEL = 500;
208
+ const MAX_LINK_TARGET = 2048;
209
+
210
+ /**
211
+ * Rewrite inline markdown links to Obsidian wikilinks.
212
+ * Fenced code blocks are left untouched. http(s) and other URLs stay as links.
213
+ *
214
+ * The scan is a single forward pass. Label and target lengths are capped so
215
+ * a hostile document cannot trigger polynomial backtracking.
216
+ * @param {string} markdown
217
+ * @param {Map<string, string>} linkMap basename/stem → note name
218
+ */
219
+ export function rewriteMarkdownToWikilinks(markdown, linkMap) {
220
+ const source = String(markdown ?? "");
221
+ const fences = fencedCodeRanges(source);
222
+ const parts = [];
223
+ let i = 0;
224
+ let fenceIndex = 0;
225
+
226
+ while (i < source.length) {
227
+ if (fenceIndex < fences.length && i === fences[fenceIndex][0]) {
228
+ parts.push(source.slice(fences[fenceIndex][0], fences[fenceIndex][1]));
229
+ i = fences[fenceIndex][1];
230
+ fenceIndex += 1;
231
+ continue;
232
+ }
233
+
234
+ const nextFence = fenceIndex < fences.length ? fences[fenceIndex][0] : source.length;
235
+ const link = scanInlineLink(source, i);
236
+ if (link && link.end <= nextFence) {
237
+ const converted = toWikilink(link.embed, link.label, link.target, linkMap);
238
+ parts.push(converted ?? link.raw);
239
+ i = link.end;
240
+ continue;
241
+ }
242
+
243
+ parts.push(source[i]);
244
+ i += 1;
245
+ }
246
+
247
+ return parts.join("");
248
+ }
249
+
250
+ /**
251
+ * Pairs of ``` fences, as [start, end) indexes. An unclosed fence is not a range.
252
+ * Each search starts at the previous match, so this is linear in the input length.
253
+ */
254
+ function fencedCodeRanges(source) {
255
+ const ranges = [];
256
+ let i = 0;
257
+ while (i < source.length) {
258
+ const open = source.indexOf("```", i);
259
+ if (open === -1) break;
260
+ const close = source.indexOf("```", open + 3);
261
+ if (close === -1) break;
262
+ ranges.push([open, close + 3]);
263
+ i = close + 3;
264
+ }
265
+ return ranges;
266
+ }
267
+
268
+ /**
269
+ * Match `![label](target)` or `[label](target)` at `index`.
270
+ * Label: 0–500 chars, no brackets or newlines. Target: 1–2048 chars, no
271
+ * parentheses or whitespace. Returns null when the text at `index` is not a link.
272
+ */
273
+ function scanInlineLink(source, index) {
274
+ let cursor = index;
275
+ let embed = false;
276
+ if (source[cursor] === "!" && source[cursor + 1] === "[") {
277
+ embed = true;
278
+ cursor += 1;
279
+ }
280
+ if (source[cursor] !== "[") return null;
281
+
282
+ const labelStart = cursor + 1;
283
+ const labelEnd = readLinkLabel(source, labelStart);
284
+ if (labelEnd < 0 || source[labelEnd + 1] !== "(") return null;
285
+
286
+ const targetStart = labelEnd + 2;
287
+ const targetEnd = readLinkTarget(source, targetStart);
288
+ if (targetEnd < 0) return null;
289
+
290
+ return {
291
+ embed,
292
+ label: source.slice(labelStart, labelEnd),
293
+ target: source.slice(targetStart, targetEnd),
294
+ raw: source.slice(index, targetEnd + 1),
295
+ end: targetEnd + 1,
296
+ };
297
+ }
298
+
299
+ function readLinkLabel(source, start) {
300
+ const limit = Math.min(source.length, start + MAX_LINK_LABEL);
301
+ let i = start;
302
+ while (i < limit) {
303
+ const ch = source[i];
304
+ if (ch === "]" || ch === "[" || ch === "\n") break;
305
+ i += 1;
306
+ }
307
+ if (i >= source.length || source[i] !== "]") return -1;
308
+ return i;
309
+ }
310
+
311
+ function readLinkTarget(source, start) {
312
+ const limit = Math.min(source.length, start + MAX_LINK_TARGET);
313
+ let i = start;
314
+ while (i < limit) {
315
+ const ch = source[i];
316
+ if (ch === "(" || ch === ")" || ch.charCodeAt(0) <= 32) break;
317
+ i += 1;
318
+ }
319
+ if (i === start || i >= source.length || source[i] !== ")") return -1;
320
+ return i;
321
+ }
322
+
323
+ function toWikilink(isEmbed, label, rawTarget, linkMap) {
324
+ const target = String(rawTarget ?? "").trim();
325
+ if (!target) return null;
326
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) return null;
327
+
328
+ if (target.startsWith("#")) {
329
+ const heading = safeHeading(target.slice(1));
330
+ if (!heading) return null;
331
+ return isEmbed ? `![[#${heading}]]` : `[[#${heading}]]`;
332
+ }
333
+
334
+ const hashAt = target.indexOf("#");
335
+ const pathPart = hashAt === -1 ? target : target.slice(0, hashAt);
336
+ const hash = hashAt === -1 ? "" : target.slice(hashAt + 1);
337
+ let decoded = pathPart;
338
+ try {
339
+ decoded = decodeURIComponent(pathPart);
340
+ } catch {
341
+ decoded = pathPart;
342
+ }
343
+ if (path.isAbsolute(decoded) || /^[A-Za-z]:[\\/]/.test(decoded)) return null;
344
+
345
+ const base = path.posix.basename(decoded.replace(/\\/g, "/"));
346
+ if (!base || base === "." || base === "..") return null;
347
+
348
+ const stem = base.replace(/\.md$/i, "");
349
+ const known = lookupNote(linkMap, base) || lookupNote(linkMap, stem);
350
+ const isMarkdown = /\.md$/i.test(base);
351
+
352
+ if (!known && !isMarkdown && !isEmbed) return null;
353
+ if (!known && isEmbed && !isMarkdown) {
354
+ return `![[${base}]]`;
355
+ }
356
+
357
+ const dest = known || sanitizeNoteName(stem);
358
+ const heading = hash ? safeHeading(hash) : "";
359
+ const targetNote = heading ? `${dest}#${heading}` : dest;
360
+ if (isEmbed) return `![[${targetNote}]]`;
361
+ const alias = String(label ?? "").trim();
362
+ if (!alias || alias === dest || alias === targetNote) return `[[${targetNote}]]`;
363
+ return `[[${targetNote}|${alias.replaceAll("|", "\\|")}]]`;
364
+ }
365
+
366
+ function safeHeading(raw) {
367
+ let decoded = raw;
368
+ try {
369
+ decoded = decodeURIComponent(raw);
370
+ } catch {
371
+ decoded = raw;
372
+ }
373
+ return decoded.replace(/[\]|#^]/g, "").trim();
374
+ }
375
+
376
+ function lookupNote(linkMap, token) {
377
+ if (!token) return null;
378
+ return linkMap.get(token) || linkMap.get(token.toLowerCase()) || null;
379
+ }
380
+
381
+ function buildLinkMap(notes) {
382
+ const map = new Map();
383
+ for (const note of notes) {
384
+ const aliases = [note.name, note.fileName, note.key, `${note.key}.md`, displayTitle(note.key)];
385
+ for (const alias of aliases) {
386
+ map.set(alias, note.name);
387
+ map.set(alias.toLowerCase(), note.name);
388
+ }
389
+ }
390
+ return map;
391
+ }
392
+
393
+ function defaultFolder(cfg) {
394
+ const raw = cfg.project?.name || "project";
395
+ const slug = sanitizeNoteName(String(raw).replace(/^\.+/, "")) || "project";
396
+ return path.join("RepoLens", slug);
397
+ }
398
+
399
+ function assertRelativeFolder(folder) {
400
+ if (typeof folder !== "string" || folder.trim() === "") {
401
+ throw new Error("obsidian.folder must be a non-empty relative path inside the vault");
402
+ }
403
+ if (folder.includes("\0")) {
404
+ throw new Error("obsidian.folder contains a null byte");
405
+ }
406
+ if (path.isAbsolute(folder) || /^[A-Za-z]:[\\/]/.test(folder)) {
407
+ throw new Error(`obsidian.folder must stay inside the vault (received absolute path ${folder})`);
408
+ }
409
+ const parts = path.normalize(folder).split(path.sep).filter((part) => part && part !== ".");
410
+ if (parts.length === 0 || parts.some((part) => part === "..")) {
411
+ throw new Error(`obsidian.folder escapes the vault: ${folder}`);
412
+ }
413
+ return parts.join(path.sep);
414
+ }
415
+
416
+ /**
417
+ * Resolve the vault directory. Relative paths are resolved from the repo root.
418
+ * The directory must already exist. The returned path is its realpath.
419
+ */
420
+ export async function resolveVaultRoot(cfg, vault) {
421
+ if (typeof vault !== "string" || vault.trim() === "") {
422
+ throw new Error(
423
+ "obsidian.vault is required. Set it to your Obsidian vault path in .repolens.yml"
424
+ );
425
+ }
426
+ if (vault.includes("\0")) {
427
+ throw new Error("obsidian.vault contains a null byte");
428
+ }
429
+ const base = cfg.__repoRoot || process.cwd();
430
+ const lexical = path.resolve(base, vault);
431
+ let real;
432
+ try {
433
+ real = await fs.realpath(lexical);
434
+ } catch (err) {
435
+ if (err.code === "ENOENT") {
436
+ throw new Error(`Obsidian vault does not exist: ${lexical}`);
437
+ }
438
+ throw err;
439
+ }
440
+ const stat = await fs.stat(real);
441
+ if (!stat.isDirectory()) {
442
+ throw new Error(`Obsidian vault is not a directory: ${lexical}`);
443
+ }
444
+ return real;
445
+ }
446
+
447
+ /**
448
+ * Resolve a folder inside the vault and create it.
449
+ * Symlinks that point outside the vault are rejected.
450
+ */
451
+ export async function resolveVaultFolder(vaultRoot, folder) {
452
+ const relative = assertRelativeFolder(folder);
453
+ const lexical = path.resolve(vaultRoot, relative);
454
+ if (!isInsideRoot(vaultRoot, lexical)) {
455
+ throw new Error(`obsidian.folder escapes the vault: ${folder}`);
456
+ }
457
+
458
+ let current = vaultRoot;
459
+ for (const part of relative.split(path.sep)) {
460
+ current = path.join(current, part);
461
+ let listed;
462
+ try {
463
+ listed = await fs.lstat(current);
464
+ } catch (err) {
465
+ if (err.code === "ENOENT") break;
466
+ throw err;
467
+ }
468
+ if (listed.isSymbolicLink()) {
469
+ const real = await fs.realpath(current);
470
+ if (!isInsideRoot(vaultRoot, real)) {
471
+ throw new Error(`obsidian.folder escapes the vault through a symlink: ${folder}`);
472
+ }
473
+ } else if (!listed.isDirectory()) {
474
+ throw new Error(`obsidian.folder is not a directory: ${folder}`);
475
+ }
476
+ }
477
+
478
+ await fs.mkdir(lexical, { recursive: true });
479
+ const realFolder = await fs.realpath(lexical);
480
+ if (!isInsideRoot(vaultRoot, realFolder)) {
481
+ throw new Error(`obsidian.folder escapes the vault: ${folder}`);
482
+ }
483
+ return realFolder;
484
+ }
485
+
486
+ function containedFile(root, fileName) {
487
+ const base = safeFileName(fileName, "");
488
+ if (!base || base === MANIFEST_FILE) {
489
+ throw new Error(`Refusing to write an unsafe note name: ${fileName}`);
490
+ }
491
+ const resolved = path.resolve(root, base);
492
+ if (!isInsideRoot(root, resolved) || path.basename(resolved) !== base) {
493
+ throw new Error(`Note path escapes the vault folder: ${fileName}`);
494
+ }
495
+ return resolved;
496
+ }
497
+
498
+ async function writeIfChanged(filePath, content) {
499
+ let listed;
500
+ try {
501
+ listed = await fs.lstat(filePath);
502
+ } catch (err) {
503
+ if (err.code !== "ENOENT") throw err;
504
+ }
505
+ if (listed?.isSymbolicLink()) {
506
+ throw new Error(`Refusing to write through a symlink: ${filePath}`);
507
+ }
508
+ if (listed && !listed.isFile()) {
509
+ throw new Error(`Refusing to overwrite a non-file: ${filePath}`);
510
+ }
511
+ if (listed?.isFile()) {
512
+ const existing = await fs.readFile(filePath, "utf8");
513
+ if (existing === content) return false;
514
+ }
515
+ await fs.writeFile(filePath, content, "utf8");
516
+ return true;
517
+ }
518
+
519
+ async function readManifest(folder) {
520
+ const manifestPath = path.join(folder, MANIFEST_FILE);
521
+ try {
522
+ const raw = await fs.readFile(manifestPath, "utf8");
523
+ const parsed = JSON.parse(raw);
524
+ return Array.isArray(parsed?.files) ? parsed.files.filter((item) => typeof item === "string") : [];
525
+ } catch (err) {
526
+ if (err.code !== "ENOENT") {
527
+ warn(`Ignoring unreadable Obsidian manifest: ${err.message}`);
528
+ }
529
+ return [];
530
+ }
531
+ }
532
+
533
+ function manifestPathFor(folder) {
534
+ const resolved = path.resolve(folder, MANIFEST_FILE);
535
+ if (!isInsideRoot(folder, resolved)) {
536
+ throw new Error("Obsidian manifest path escapes the vault folder");
537
+ }
538
+ return resolved;
539
+ }
540
+
541
+ function removableManagedFile(folder, rel) {
542
+ if (typeof rel !== "string" || rel.includes("\0") || path.isAbsolute(rel)) return null;
543
+ const parts = rel.split(/[\\/]/).filter(Boolean);
544
+ if (parts.length !== 1 || parts[0] === "." || parts[0] === ".." || parts[0] === MANIFEST_FILE) {
545
+ return null;
546
+ }
547
+ const resolved = path.resolve(folder, parts[0]);
548
+ if (!isInsideRoot(folder, resolved)) return null;
549
+ return resolved;
550
+ }
551
+
552
+ async function syncManifest(vaultRoot, folder, fileNames) {
553
+ const previous = await readManifest(folder);
554
+ const next = new Set(fileNames);
555
+ let removed = 0;
556
+ for (const rel of previous) {
557
+ if (next.has(rel)) continue;
558
+ const target = removableManagedFile(folder, rel);
559
+ if (!target || !isInsideRoot(vaultRoot, target)) continue;
560
+ let listed;
561
+ try {
562
+ listed = await fs.lstat(target);
563
+ } catch (err) {
564
+ if (err.code === "ENOENT") continue;
565
+ throw err;
566
+ }
567
+ if (listed.isSymbolicLink() || !listed.isFile()) continue;
568
+ await fs.unlink(target);
569
+ removed += 1;
570
+ }
571
+
572
+ const body = `${JSON.stringify({ version: 1, files: [...fileNames].sort() }, null, 2)}\n`;
573
+ await writeIfChanged(manifestPathFor(folder), body);
574
+ return removed;
575
+ }
576
+
577
+ function renderPage(note, markdown, options) {
578
+ const body = options.useWikilinks
579
+ ? rewriteMarkdownToWikilinks(markdown, options.linkMap)
580
+ : String(markdown);
581
+ const backlink = options.useIndex
582
+ ? (options.useWikilinks ? `← [[${INDEX_NOTE}|Index]]` : `← [Index](${INDEX_NOTE}.md)`)
583
+ : "";
584
+ const parts = [];
585
+ if (options.useFrontmatter) {
586
+ const aliases = [note.key];
587
+ const title = displayTitle(note.key);
588
+ if (title !== note.name) aliases.push(title);
589
+ parts.push(buildFrontmatter({
590
+ title: note.name,
591
+ aliases,
592
+ tags: ["repolens", PAGE_TAGS[note.key]].filter(Boolean),
593
+ type: note.key,
594
+ project: options.projectName,
595
+ branch: options.branch,
596
+ publisher: "repolens",
597
+ }));
598
+ }
599
+ if (backlink) parts.push(`${backlink}\n`);
600
+ parts.push(body.trimEnd());
601
+ return ensureTrailingNewline(parts.join("\n"));
602
+ }
603
+
604
+ function renderIndex(notes, options) {
605
+ const byKey = new Map(notes.map((note) => [note.key, note]));
606
+ const listed = new Set();
607
+ const sections = [];
608
+
609
+ for (const group of AUDIENCE_GROUPS) {
610
+ const items = group.keys.filter((key) => byKey.has(key));
611
+ if (items.length === 0) continue;
612
+ sections.push(`## ${group.title}`, "");
613
+ for (const key of items) {
614
+ listed.add(key);
615
+ sections.push(indexItem(byKey.get(key), options.useWikilinks));
616
+ }
617
+ sections.push("");
618
+ }
619
+
620
+ const custom = notes.filter((note) => !listed.has(note.key));
621
+ if (custom.length > 0) {
622
+ sections.push("## Custom", "");
623
+ for (const note of custom) sections.push(indexItem(note, options.useWikilinks));
624
+ sections.push("");
625
+ }
626
+
627
+ const intro = [
628
+ `# ${options.projectName}`,
629
+ "",
630
+ "Architecture notes published by RepoLens into this Obsidian vault.",
631
+ "Re-running publish updates these notes in place.",
632
+ "",
633
+ ];
634
+
635
+ const parts = [];
636
+ if (options.useFrontmatter) {
637
+ const aliases = options.projectName && options.projectName !== INDEX_NOTE
638
+ ? [options.projectName]
639
+ : [];
640
+ parts.push(buildFrontmatter({
641
+ title: INDEX_NOTE,
642
+ aliases,
643
+ tags: ["repolens", "index"],
644
+ type: "index",
645
+ project: options.projectName,
646
+ branch: options.branch,
647
+ publisher: "repolens",
648
+ }));
649
+ }
650
+ parts.push([...intro, ...sections].join("\n").trimEnd());
651
+ return ensureTrailingNewline(parts.join("\n"));
652
+ }
653
+
654
+ function indexItem(note, useWikilinks) {
655
+ const description = PAGE_DESCRIPTIONS[note.key] || "Generated by RepoLens.";
656
+ const link = useWikilinks ? `[[${note.name}]]` : `[${note.name}](${note.fileName})`;
657
+ return `- ${link} — ${description}`;
658
+ }
659
+
660
+ function orderedKeys(renderedPages) {
661
+ const present = Object.entries(renderedPages)
662
+ .filter(([, markdown]) => typeof markdown === "string" && markdown.trim() !== "")
663
+ .map(([key]) => key);
664
+ const known = PAGE_ORDER.filter((key) => present.includes(key));
665
+ const custom = present.filter((key) => !PAGE_ORDER.includes(key));
666
+ return [...known, ...custom];
667
+ }
668
+
669
+ /**
670
+ * Publish rendered pages into an Obsidian vault folder.
671
+ * @returns {Promise<{ folder: string, files: string[], removed: number, changed: number }>}
672
+ */
673
+ export async function publishToObsidian(cfg, renderedPages) {
674
+ const obsidian = cfg.obsidian || {};
675
+ const vaultRoot = await resolveVaultRoot(cfg, obsidian.vault);
676
+ const folderRel = obsidian.folder == null || obsidian.folder === ""
677
+ ? defaultFolder(cfg)
678
+ : obsidian.folder;
679
+ const folder = await resolveVaultFolder(vaultRoot, folderRel);
680
+
681
+ const useFrontmatter = obsidian.frontmatter !== false;
682
+ const useWikilinks = obsidian.wikilinks !== false;
683
+ const useIndex = obsidian.index !== false;
684
+ const projectName = cfg.project?.name || "RepoLens";
685
+ const branch = getCurrentBranch();
686
+
687
+ const notes = allocateNotes(orderedKeys(renderedPages || {}), useWikilinks);
688
+ const linkMap = buildLinkMap(notes);
689
+ const fileNames = [];
690
+ let changed = 0;
691
+
692
+ for (const note of notes) {
693
+ const filePath = containedFile(folder, note.fileName);
694
+ const content = renderPage(note, renderedPages[note.key], {
695
+ useFrontmatter,
696
+ useWikilinks,
697
+ useIndex,
698
+ linkMap,
699
+ projectName,
700
+ branch,
701
+ });
702
+ if (await writeIfChanged(filePath, content)) changed += 1;
703
+ fileNames.push(note.fileName);
704
+ }
705
+
706
+ if (useIndex) {
707
+ const indexPath = containedFile(folder, `${INDEX_NOTE}.md`);
708
+ const content = renderIndex(notes, { useFrontmatter, useWikilinks, projectName, branch });
709
+ if (await writeIfChanged(indexPath, content)) changed += 1;
710
+ fileNames.push(`${INDEX_NOTE}.md`);
711
+ }
712
+
713
+ const removed = await syncManifest(vaultRoot, folder, fileNames);
714
+ info(`obsidian notes written to ${folder} (${fileNames.length} notes, ${changed} updated, ${removed} removed)`);
715
+ return { folder, files: fileNames, removed, changed };
716
+ }