@plebeiantech/lat.md 0.12.3-fork.1

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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/dist/src/cli/check-frontmatter.d.ts +12 -0
  4. package/dist/src/cli/check-frontmatter.js +44 -0
  5. package/dist/src/cli/check-mode.d.ts +6 -0
  6. package/dist/src/cli/check-mode.js +303 -0
  7. package/dist/src/cli/check-status.d.ts +74 -0
  8. package/dist/src/cli/check-status.js +217 -0
  9. package/dist/src/cli/check.d.ts +45 -0
  10. package/dist/src/cli/check.js +777 -0
  11. package/dist/src/cli/checklist-menu.d.ts +12 -0
  12. package/dist/src/cli/checklist-menu.js +111 -0
  13. package/dist/src/cli/comment-guard.d.ts +16 -0
  14. package/dist/src/cli/comment-guard.js +111 -0
  15. package/dist/src/cli/comment-reminder.d.ts +62 -0
  16. package/dist/src/cli/comment-reminder.js +210 -0
  17. package/dist/src/cli/context.d.ts +11 -0
  18. package/dist/src/cli/context.js +58 -0
  19. package/dist/src/cli/expand.d.ts +8 -0
  20. package/dist/src/cli/expand.js +94 -0
  21. package/dist/src/cli/gen-index.d.ts +45 -0
  22. package/dist/src/cli/gen-index.js +235 -0
  23. package/dist/src/cli/gen.d.ts +6 -0
  24. package/dist/src/cli/gen.js +42 -0
  25. package/dist/src/cli/graph.d.ts +7 -0
  26. package/dist/src/cli/graph.js +47 -0
  27. package/dist/src/cli/hook.d.ts +1 -0
  28. package/dist/src/cli/hook.js +432 -0
  29. package/dist/src/cli/index.d.ts +2 -0
  30. package/dist/src/cli/index.js +307 -0
  31. package/dist/src/cli/init.d.ts +9 -0
  32. package/dist/src/cli/init.js +1031 -0
  33. package/dist/src/cli/link-scheme.d.ts +54 -0
  34. package/dist/src/cli/link-scheme.js +101 -0
  35. package/dist/src/cli/locate.d.ts +2 -0
  36. package/dist/src/cli/locate.js +17 -0
  37. package/dist/src/cli/refs.d.ts +22 -0
  38. package/dist/src/cli/refs.js +229 -0
  39. package/dist/src/cli/reindex.d.ts +13 -0
  40. package/dist/src/cli/reindex.js +136 -0
  41. package/dist/src/cli/search.d.ts +36 -0
  42. package/dist/src/cli/search.js +217 -0
  43. package/dist/src/cli/section.d.ts +43 -0
  44. package/dist/src/cli/section.js +252 -0
  45. package/dist/src/cli/select-menu.d.ts +13 -0
  46. package/dist/src/cli/select-menu.js +100 -0
  47. package/dist/src/cli/templates.d.ts +1 -0
  48. package/dist/src/cli/templates.js +15 -0
  49. package/dist/src/cli/ui.d.ts +11 -0
  50. package/dist/src/cli/ui.js +34 -0
  51. package/dist/src/code-refs.d.ts +17 -0
  52. package/dist/src/code-refs.js +277 -0
  53. package/dist/src/config.d.ts +29 -0
  54. package/dist/src/config.js +83 -0
  55. package/dist/src/context.d.ts +23 -0
  56. package/dist/src/context.js +11 -0
  57. package/dist/src/extensions/wiki-link/from-markdown.d.ts +5 -0
  58. package/dist/src/extensions/wiki-link/from-markdown.js +34 -0
  59. package/dist/src/extensions/wiki-link/index.d.ts +4 -0
  60. package/dist/src/extensions/wiki-link/index.js +3 -0
  61. package/dist/src/extensions/wiki-link/syntax.d.ts +14 -0
  62. package/dist/src/extensions/wiki-link/syntax.js +121 -0
  63. package/dist/src/extensions/wiki-link/to-markdown.d.ts +5 -0
  64. package/dist/src/extensions/wiki-link/to-markdown.js +28 -0
  65. package/dist/src/extensions/wiki-link/types.d.ts +34 -0
  66. package/dist/src/extensions/wiki-link/types.js +1 -0
  67. package/dist/src/format.d.ts +8 -0
  68. package/dist/src/format.js +43 -0
  69. package/dist/src/graph-export.d.ts +60 -0
  70. package/dist/src/graph-export.js +309 -0
  71. package/dist/src/init-version.d.ts +10 -0
  72. package/dist/src/init-version.js +49 -0
  73. package/dist/src/knowledge/bd.d.ts +2 -0
  74. package/dist/src/knowledge/bd.js +107 -0
  75. package/dist/src/knowledge/claude-memory.d.ts +13 -0
  76. package/dist/src/knowledge/claude-memory.js +204 -0
  77. package/dist/src/knowledge/cq.d.ts +2 -0
  78. package/dist/src/knowledge/cq.js +74 -0
  79. package/dist/src/knowledge/index.d.ts +49 -0
  80. package/dist/src/knowledge/index.js +271 -0
  81. package/dist/src/knowledge/ranking.d.ts +17 -0
  82. package/dist/src/knowledge/ranking.js +62 -0
  83. package/dist/src/knowledge/session.d.ts +46 -0
  84. package/dist/src/knowledge/session.js +203 -0
  85. package/dist/src/knowledge/types.d.ts +47 -0
  86. package/dist/src/knowledge/types.js +10 -0
  87. package/dist/src/lattice.d.ts +108 -0
  88. package/dist/src/lattice.js +816 -0
  89. package/dist/src/mcp/server.d.ts +1 -0
  90. package/dist/src/mcp/server.js +58 -0
  91. package/dist/src/parser.d.ts +3 -0
  92. package/dist/src/parser.js +18 -0
  93. package/dist/src/search/db.d.ts +16 -0
  94. package/dist/src/search/db.js +54 -0
  95. package/dist/src/search/embedder.d.ts +25 -0
  96. package/dist/src/search/embedder.js +58 -0
  97. package/dist/src/search/index.d.ts +9 -0
  98. package/dist/src/search/index.js +66 -0
  99. package/dist/src/search/search.d.ts +9 -0
  100. package/dist/src/search/search.js +16 -0
  101. package/dist/src/source-parser.d.ts +27 -0
  102. package/dist/src/source-parser.js +916 -0
  103. package/dist/src/untrusted.d.ts +24 -0
  104. package/dist/src/untrusted.js +97 -0
  105. package/dist/src/version.d.ts +7 -0
  106. package/dist/src/version.js +39 -0
  107. package/dist/src/view/client/assets/index-B0n0u03y.css +1 -0
  108. package/dist/src/view/client/assets/index-xm2_px4q.js +23 -0
  109. package/dist/src/view/client/index.html +14 -0
  110. package/dist/src/view/diagnostics.d.ts +5 -0
  111. package/dist/src/view/diagnostics.js +171 -0
  112. package/dist/src/view/git-diff.d.ts +3 -0
  113. package/dist/src/view/git-diff.js +255 -0
  114. package/dist/src/view/git.d.ts +22 -0
  115. package/dist/src/view/git.js +193 -0
  116. package/dist/src/view/highlight.d.ts +2 -0
  117. package/dist/src/view/highlight.js +3767 -0
  118. package/dist/src/view/markdown.d.ts +22 -0
  119. package/dist/src/view/markdown.js +322 -0
  120. package/dist/src/view/protocol.d.ts +89 -0
  121. package/dist/src/view/protocol.js +1 -0
  122. package/dist/src/view/references.d.ts +62 -0
  123. package/dist/src/view/references.js +345 -0
  124. package/dist/src/view/repository.d.ts +12 -0
  125. package/dist/src/view/repository.js +167 -0
  126. package/dist/src/view/search.d.ts +14 -0
  127. package/dist/src/view/search.js +64 -0
  128. package/dist/src/view/server.d.ts +20 -0
  129. package/dist/src/view/server.js +233 -0
  130. package/dist/src/view/store.d.ts +57 -0
  131. package/dist/src/view/store.js +404 -0
  132. package/dist/src/walk.d.ts +18 -0
  133. package/dist/src/walk.js +27 -0
  134. package/package.json +85 -0
  135. package/templates/AGENTS.md +118 -0
  136. package/templates/README +1 -0
  137. package/templates/cursor-rules.md +77 -0
  138. package/templates/init/lat.md +1 -0
  139. package/templates/logo-dark.svg +182 -0
  140. package/templates/logo-light.svg +182 -0
  141. package/templates/logo.txt +7 -0
  142. package/templates/opencode-plugin.ts +180 -0
  143. package/templates/pi-extension.ts +364 -0
  144. package/templates/skill/SKILL.md +189 -0
@@ -0,0 +1,816 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { dirname, join, basename, relative, resolve } from 'node:path';
3
+ import { existsSync, statSync } from 'node:fs';
4
+ import GithubSlugger from 'github-slugger';
5
+ import { parse as parseYaml } from 'yaml';
6
+ import { parse } from './parser.js';
7
+ import { toPosix, walkEntries } from './walk.js';
8
+ import { visit } from 'unist-util-visit';
9
+ /**
10
+ * Every field this build reads out of the `lat:` mapping. Kept in one place so
11
+ * a field added later is covered by the misplacement check without a second
12
+ * edit — the trap this list guards against is silent, so it must not depend on
13
+ * whoever adds the next field remembering to register it twice.
14
+ */
15
+ export const LAT_FIELDS = [
16
+ 'require-code-mention',
17
+ 'mode',
18
+ 'status',
19
+ 'reviewed-hash',
20
+ 'tags',
21
+ ];
22
+ export function parseFrontmatter(content) {
23
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
24
+ if (!match)
25
+ return { raw: {} };
26
+ let raw = {};
27
+ const problems = [];
28
+ try {
29
+ const parsed = parseYaml(match[1]);
30
+ if (parsed !== null &&
31
+ typeof parsed === 'object' &&
32
+ !Array.isArray(parsed)) {
33
+ const root = parsed;
34
+ const lat = root['lat'];
35
+ const hasLatMapping = lat !== null && typeof lat === 'object' && !Array.isArray(lat);
36
+ if (hasLatMapping) {
37
+ raw = lat;
38
+ }
39
+ // A field written at the document root was accepted by the regex reader
40
+ // this parser replaced. Staying quiet about it is what makes the change
41
+ // dangerous: the field simply stops being read.
42
+ for (const field of LAT_FIELDS) {
43
+ if (Object.hasOwn(root, field) && !Object.hasOwn(raw, field)) {
44
+ problems.push({ kind: 'root-level-field', field });
45
+ }
46
+ }
47
+ }
48
+ }
49
+ catch (err) {
50
+ // One malformed line anywhere in the block used to be harmless, because
51
+ // the old reader pattern-matched the raw text. A strict parse throws away
52
+ // every field instead, so the failure has to be reported rather than
53
+ // swallowed.
54
+ raw = {};
55
+ problems.push({
56
+ kind: 'parse-error',
57
+ message: err.message.split('\n')[0],
58
+ });
59
+ }
60
+ const result = { raw };
61
+ if (raw['require-code-mention'] === true) {
62
+ result.requireCodeMention = true;
63
+ }
64
+ if (problems.length > 0)
65
+ result.problems = problems;
66
+ return result;
67
+ }
68
+ export function findLatticeDir(from) {
69
+ let dir = resolve(from ?? process.cwd());
70
+ while (true) {
71
+ const candidate = join(dir, 'lat.md');
72
+ if (existsSync(candidate) && statSync(candidate).isDirectory()) {
73
+ return candidate;
74
+ }
75
+ const parent = dirname(dir);
76
+ if (parent === dir)
77
+ return null;
78
+ dir = parent;
79
+ }
80
+ }
81
+ export function findProjectRoot(from) {
82
+ const latDir = findLatticeDir(from);
83
+ return latDir ? dirname(latDir) : null;
84
+ }
85
+ export async function listLatticeFiles(latticeDir) {
86
+ const entries = await walkEntries(latticeDir);
87
+ return entries
88
+ .filter((e) => e.endsWith('.md'))
89
+ .sort()
90
+ .map((e) => join(latticeDir, e));
91
+ }
92
+ function headingText(node) {
93
+ return node.children
94
+ .filter((c) => c.type === 'text')
95
+ .map((c) => c.value)
96
+ .join('');
97
+ }
98
+ /** Extract the rendered text GitHub uses as input to its heading slugger. */
99
+ function githubHeadingText(node) {
100
+ if (!node || typeof node !== 'object')
101
+ return '';
102
+ const value = node;
103
+ if (value.type === 'text' || value.type === 'inlineCode') {
104
+ return value.value ?? '';
105
+ }
106
+ if (value.type === 'image' || value.type === 'imageReference') {
107
+ return value.alt ?? '';
108
+ }
109
+ if (value.type === 'wikiLink') {
110
+ return value.data?.alias ?? value.value ?? '';
111
+ }
112
+ if (value.children) {
113
+ return value.children.map(githubHeadingText).join('');
114
+ }
115
+ return '';
116
+ }
117
+ function inlineText(node) {
118
+ return node.children
119
+ .map((c) => {
120
+ if (c.type === 'text')
121
+ return c.value;
122
+ if (c.type === 'inlineCode')
123
+ return '`' + c.value + '`';
124
+ if (c.type === 'wikiLink')
125
+ return '[[' + c.value + ']]';
126
+ if ('children' in c)
127
+ return inlineText(c);
128
+ return '';
129
+ })
130
+ .join('');
131
+ }
132
+ function lastLine(content) {
133
+ const lines = content.split('\n');
134
+ // If trailing newline, count doesn't include empty last line
135
+ return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length;
136
+ }
137
+ export function parseSections(filePath, content, projectRoot, tree = parse(content)) {
138
+ const file = projectRoot
139
+ ? toPosix(relative(projectRoot, filePath)).replace(/\.md$/, '')
140
+ : basename(filePath, '.md');
141
+ const sectionFilePath = projectRoot
142
+ ? toPosix(relative(projectRoot, filePath))
143
+ : basename(filePath);
144
+ const roots = [];
145
+ const stack = [];
146
+ const flat = [];
147
+ const slugger = new GithubSlugger();
148
+ visit(tree, 'heading', (node) => {
149
+ const heading = headingText(node);
150
+ const depth = node.depth;
151
+ const startLine = node.position.start.line;
152
+ // Pop stack until we find a parent with smaller depth
153
+ while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
154
+ stack.pop();
155
+ }
156
+ const parent = stack.length > 0 ? stack[stack.length - 1] : null;
157
+ const id = parent ? `${parent.id}#${heading}` : `${file}#${heading}`;
158
+ const section = {
159
+ id,
160
+ heading,
161
+ depth,
162
+ file,
163
+ filePath: sectionFilePath,
164
+ children: [],
165
+ startLine,
166
+ endLine: 0,
167
+ firstParagraph: '',
168
+ githubSlug: slugger.slug(githubHeadingText(node)),
169
+ };
170
+ if (parent) {
171
+ parent.children.push(section);
172
+ }
173
+ else {
174
+ roots.push(section);
175
+ }
176
+ stack.push(section);
177
+ flat.push(section);
178
+ });
179
+ // Compute endLine: line before next heading or last line of file
180
+ const fileLastLine = lastLine(content);
181
+ for (let i = 0; i < flat.length; i++) {
182
+ if (i + 1 < flat.length) {
183
+ flat[i].endLine = flat[i + 1].startLine - 1;
184
+ }
185
+ else {
186
+ flat[i].endLine = fileLastLine;
187
+ }
188
+ }
189
+ // Extract firstParagraph: first paragraph after each heading
190
+ const children = tree.children;
191
+ let headingIdx = 0;
192
+ for (let i = 0; i < children.length; i++) {
193
+ const node = children[i];
194
+ if (node.type === 'heading') {
195
+ // Find the first paragraph after this heading, before the next heading
196
+ for (let j = i + 1; j < children.length; j++) {
197
+ if (children[j].type === 'heading')
198
+ break;
199
+ if (children[j].type === 'paragraph') {
200
+ flat[headingIdx].firstParagraph = inlineText(children[j]);
201
+ break;
202
+ }
203
+ }
204
+ headingIdx++;
205
+ }
206
+ }
207
+ return roots;
208
+ }
209
+ export async function loadAllSections(latticeDir, projectRoot = dirname(latticeDir)) {
210
+ const files = await listLatticeFiles(latticeDir);
211
+ const all = [];
212
+ for (const file of files) {
213
+ const content = await readFile(file, 'utf-8');
214
+ all.push(...parseSections(file, content, projectRoot));
215
+ }
216
+ return all;
217
+ }
218
+ export function flattenSections(sections) {
219
+ const result = [];
220
+ for (const s of sections) {
221
+ result.push(s);
222
+ result.push(...flattenSections(s.children));
223
+ }
224
+ return result;
225
+ }
226
+ function levenshtein(a, b) {
227
+ const m = a.length;
228
+ const n = b.length;
229
+ const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
230
+ for (let i = 0; i <= m; i++)
231
+ dp[i][0] = i;
232
+ for (let j = 0; j <= n; j++)
233
+ dp[0][j] = j;
234
+ for (let i = 1; i <= m; i++) {
235
+ for (let j = 1; j <= n; j++) {
236
+ dp[i][j] =
237
+ a[i - 1] === b[j - 1]
238
+ ? dp[i - 1][j - 1]
239
+ : 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
240
+ }
241
+ }
242
+ return dp[m][n];
243
+ }
244
+ /**
245
+ * Returns the trailing segment(s) of a section id.
246
+ * e.g. "markdown#Frontmatter#require-code-mention" → ["Frontmatter#require-code-mention", "require-code-mention"]
247
+ * The full id itself is not included (handled by exact match).
248
+ */
249
+ function tailSegments(id) {
250
+ const parts = id.split('#');
251
+ const tails = [];
252
+ for (let i = 1; i < parts.length; i++) {
253
+ tails.push(parts.slice(i).join('#'));
254
+ }
255
+ return tails;
256
+ }
257
+ /**
258
+ * Build an index mapping path suffixes to their full vault-relative paths.
259
+ * Used by resolveRef to allow short references when a suffix is unambiguous.
260
+ *
261
+ * For a file like `lat.md/guides/setup`, indexes both `guides/setup` and `setup`.
262
+ * This ensures backward-compatible short refs after the vault root moved to the
263
+ * project root (so section IDs now include the `lat.md/` prefix).
264
+ */
265
+ export function buildFileIndex(sections) {
266
+ const flat = flattenSections(sections);
267
+ const index = new Map();
268
+ for (const s of flat) {
269
+ const parts = s.file.split('/');
270
+ // Index all trailing path suffixes (excluding the full path itself,
271
+ // which is handled by exact match). Keys are lowercase for
272
+ // case-insensitive lookup.
273
+ for (let i = 1; i < parts.length; i++) {
274
+ const suffix = parts.slice(i).join('/').toLowerCase();
275
+ if (!index.has(suffix))
276
+ index.set(suffix, new Set());
277
+ index.get(suffix).add(s.file);
278
+ }
279
+ }
280
+ const result = new Map();
281
+ for (const [stem, paths] of index) {
282
+ result.set(stem, [...paths]);
283
+ }
284
+ return result;
285
+ }
286
+ /**
287
+ * Map GitHub-slugged heading paths back to canonical, literal-heading ids.
288
+ * Every path segment accepts either representation so an implicit literal h1
289
+ * can still prefix slugged child headings during short-ref resolution.
290
+ */
291
+ export function buildSectionSlugIndex(sections) {
292
+ const aliases = new Map();
293
+ const stacks = new Map();
294
+ const sluggers = new Map();
295
+ for (const section of flattenSections(sections)) {
296
+ let slugger = sluggers.get(section.file);
297
+ if (!slugger) {
298
+ slugger = new GithubSlugger();
299
+ sluggers.set(section.file, slugger);
300
+ }
301
+ // Always advance the fallback slugger so manually constructed Section
302
+ // objects without githubSlug still get correct duplicate suffixes.
303
+ const fallbackSlug = slugger.slug(section.heading);
304
+ const headingSlug = section.githubSlug ?? fallbackSlug;
305
+ const stack = stacks.get(section.file) ?? [];
306
+ while (stack.length > 0 && stack[stack.length - 1].depth >= section.depth) {
307
+ stack.pop();
308
+ }
309
+ const levels = [
310
+ ...stack,
311
+ {
312
+ depth: section.depth,
313
+ literal: section.heading.toLowerCase(),
314
+ slug: headingSlug.toLowerCase(),
315
+ },
316
+ ];
317
+ let paths = [''];
318
+ for (const level of levels) {
319
+ const options = [...new Set([level.literal, level.slug])];
320
+ paths = paths.flatMap((path) => options.map((part) => (path ? `${path}#${part}` : part)));
321
+ }
322
+ for (const path of paths) {
323
+ const alias = `${section.file.toLowerCase()}#${path}`;
324
+ if (!aliases.has(alias))
325
+ aliases.set(alias, section.id);
326
+ }
327
+ stack.push(levels[levels.length - 1]);
328
+ stacks.set(section.file, stack);
329
+ }
330
+ return aliases;
331
+ }
332
+ /**
333
+ * Normalize separators only in the file portion of a section reference.
334
+ * Headings are authored text and may legitimately contain backslashes.
335
+ */
336
+ function normalizeRefFilePath(target) {
337
+ const hashIdx = target.indexOf('#');
338
+ if (hashIdx === -1)
339
+ return toPosix(target);
340
+ return toPosix(target.slice(0, hashIdx)) + target.slice(hashIdx);
341
+ }
342
+ /**
343
+ * Resolve a potentially short reference to its canonical full-path form.
344
+ * If the file segment of the ref is a bare stem that uniquely maps to one
345
+ * full path, expands it. Otherwise returns the ref unchanged.
346
+ *
347
+ * When ambiguous (multiple files share the stem), returns all candidates.
348
+ * If exactly one candidate actually contains the referenced section,
349
+ * `suggested` is set to that candidate so the caller can propose a fix.
350
+ */
351
+ export function resolveRef(target, sectionIds, fileIndex, slugIndex) {
352
+ target = normalizeRefFilePath(target);
353
+ const resolveKnown = (candidate) => {
354
+ // Preserve existing wiki-link meaning when a literal heading happens to
355
+ // collide with a different heading's GitHub slug.
356
+ if (sectionIds.has(candidate.toLowerCase()))
357
+ return candidate;
358
+ return slugIndex?.get(candidate.toLowerCase()) ?? null;
359
+ };
360
+ const resolveInFile = (file, rest) => {
361
+ const direct = resolveKnown(file + rest);
362
+ if (direct)
363
+ return direct;
364
+ // Try inserting root headings between file and rest. This also works for
365
+ // slugged child segments because the slug index contains mixed paths.
366
+ const rootHeadings = findRootHeadings(file, sectionIds);
367
+ for (const h1 of rootHeadings) {
368
+ const withRoot = rest ? `${file}#${h1}${rest}` : `${file}#${h1}`;
369
+ const resolved = resolveKnown(withRoot);
370
+ if (resolved)
371
+ return resolved;
372
+ }
373
+ return null;
374
+ };
375
+ // Already matches a known section — no resolution needed
376
+ const direct = resolveKnown(target);
377
+ if (direct) {
378
+ return { resolved: direct, ambiguous: null, suggested: null };
379
+ }
380
+ // Extract the file segment (before first #) and try resolving it
381
+ const hashIdx = target.indexOf('#');
382
+ const filePart = hashIdx === -1 ? target : target.slice(0, hashIdx);
383
+ const rest = hashIdx === -1 ? '' : target.slice(hashIdx);
384
+ // Try resolving the file part: either it's a full path or a bare stem
385
+ // File index keys are lowercase for case-insensitive lookup.
386
+ const lcFilePart = filePart.toLowerCase();
387
+ const filePaths = fileIndex.has(lcFilePart)
388
+ ? fileIndex.get(lcFilePart)
389
+ : [filePart];
390
+ if (filePaths.length === 1) {
391
+ const fp = filePaths[0];
392
+ const resolved = resolveInFile(fp, rest);
393
+ if (resolved) {
394
+ return { resolved, ambiguous: null, suggested: null };
395
+ }
396
+ }
397
+ else if (filePaths.length > 1) {
398
+ // Multiple files share this stem — ambiguous at the filename level
399
+ const all = filePaths.map((c) => c + rest);
400
+ const valid = filePaths.filter((c) => resolveInFile(c, rest) !== null);
401
+ return {
402
+ resolved: target,
403
+ ambiguous: all,
404
+ suggested: valid.length === 1 ? valid[0] + rest : null,
405
+ };
406
+ }
407
+ return { resolved: target, ambiguous: null, suggested: null };
408
+ }
409
+ /**
410
+ * Find root (h1) headings for a file by scanning sectionIds for entries
411
+ * that have exactly the pattern `file#heading` (no further # segments).
412
+ */
413
+ function findRootHeadings(file, sectionIds) {
414
+ const prefix = file.toLowerCase() + '#';
415
+ const headings = [];
416
+ for (const id of sectionIds) {
417
+ if (id.startsWith(prefix) && !id.includes('#', prefix.length)) {
418
+ headings.push(id.slice(prefix.length));
419
+ }
420
+ }
421
+ return headings;
422
+ }
423
+ const MAX_DISTANCE_RATIO = 0.4;
424
+ export function findSections(sections, query) {
425
+ const flat = flattenSections(sections);
426
+ // Leading # means "search for a heading", strip it
427
+ const normalized = normalizeRefFilePath(query.startsWith('#') ? query.slice(1) : query);
428
+ const q = normalized.toLowerCase();
429
+ const isFullPath = normalized.includes('#');
430
+ const byId = new Map(flat.map((s) => [s.id.toLowerCase(), s]));
431
+ const slugIndex = buildSectionSlugIndex(sections);
432
+ const sectionFor = (candidate) => {
433
+ const key = candidate.toLowerCase();
434
+ return byId.get(key) ?? byId.get(slugIndex.get(key)?.toLowerCase() ?? '');
435
+ };
436
+ // Tier 1: exact full-id match
437
+ const literalExact = flat.filter((s) => s.id.toLowerCase() === q);
438
+ const slugExact = literalExact.length === 0 && isFullPath
439
+ ? sectionFor(normalized)
440
+ : undefined;
441
+ const exact = slugExact ? [slugExact] : literalExact;
442
+ const exactMatches = exact.map((s) => ({
443
+ section: s,
444
+ reason: 'exact match',
445
+ }));
446
+ if (exactMatches.length > 0 && isFullPath)
447
+ return exactMatches;
448
+ // Build file index early — used by both tier 1a and 1b
449
+ const fileIndex = buildFileIndex(sections);
450
+ // Tier 1a: bare name matches file — return root sections of that file
451
+ // Also checks via file index (e.g. "dev-process" → "lat.md/dev-process")
452
+ if (!isFullPath && exactMatches.length === 0) {
453
+ const matchFiles = new Set();
454
+ // Direct match
455
+ for (const s of flat) {
456
+ if (s.file.toLowerCase() === q &&
457
+ !s.id.includes('#', s.file.length + 1)) {
458
+ matchFiles.add(s.file);
459
+ }
460
+ }
461
+ // File index expansion (keys are lowercase)
462
+ const indexPaths = fileIndex.get(q) ?? [];
463
+ for (const p of indexPaths) {
464
+ matchFiles.add(p);
465
+ }
466
+ if (matchFiles.size > 0) {
467
+ const fileRoots = flat.filter((s) => matchFiles.has(s.file) && !s.id.includes('#', s.file.length + 1));
468
+ if (fileRoots.length > 0) {
469
+ return fileRoots.map((s) => ({
470
+ section: s,
471
+ reason: 'exact match',
472
+ }));
473
+ }
474
+ }
475
+ }
476
+ // Tier 1b: file stem expansion
477
+ // For bare names: "locate" → matches root section of "tests/locate.md"
478
+ // For paths with #: "setup#Install" → expands to "guides/setup#Install"
479
+ const stemMatches = [];
480
+ if (isFullPath) {
481
+ // Expand file stem in the file part of the query
482
+ const hashIdx = normalized.indexOf('#');
483
+ const filePart = normalized.slice(0, hashIdx);
484
+ const rest = normalized.slice(hashIdx);
485
+ const stemPaths = fileIndex.get(filePart.toLowerCase()) ?? [];
486
+ // Also try filePart as a direct file path (for root-level files not in index)
487
+ const allPaths = stemPaths.length > 0 ? stemPaths : filePart ? [filePart] : [];
488
+ for (const p of allPaths) {
489
+ const s = sectionFor(p + rest);
490
+ if (s) {
491
+ if (exact.includes(s))
492
+ continue;
493
+ stemMatches.push({
494
+ section: s,
495
+ reason: stemPaths.length > 0
496
+ ? `file stem expanded: ${filePart} → ${p}`
497
+ : 'exact match',
498
+ });
499
+ continue;
500
+ }
501
+ // Try inserting root headings: file#rest → file#h1#rest
502
+ const rootsOfFile = flat.filter((s) => s.file.toLowerCase() === p.toLowerCase() &&
503
+ !s.id.includes('#', s.file.length + 1));
504
+ for (const root of rootsOfFile) {
505
+ const match = sectionFor(root.id + rest);
506
+ if (match) {
507
+ if (exact.includes(match))
508
+ continue;
509
+ stemMatches.push({
510
+ section: match,
511
+ reason: stemPaths.length > 0
512
+ ? `file stem expanded: ${filePart} → ${p}`
513
+ : 'exact match',
514
+ });
515
+ }
516
+ }
517
+ }
518
+ if (stemMatches.length > 0)
519
+ return [...exactMatches, ...stemMatches];
520
+ }
521
+ else {
522
+ // Bare name: match root sections of files via stem index (keys lowercase)
523
+ const paths = fileIndex.get(q) ?? [];
524
+ for (const p of paths) {
525
+ for (const s of flat) {
526
+ if (exact.includes(s))
527
+ continue;
528
+ // Root sections have id = "file#heading" (exactly 2 segments)
529
+ if (s.file.toLowerCase() === p.toLowerCase() &&
530
+ !s.id.includes('#', s.file.length + 1)) {
531
+ stemMatches.push({ section: s, reason: 'file stem match' });
532
+ }
533
+ }
534
+ }
535
+ }
536
+ // Tier 2: exact match on trailing segments (subsection name match)
537
+ const seen = new Set([
538
+ ...exact.map((s) => s.id),
539
+ ...stemMatches.map((m) => m.section.id),
540
+ ]);
541
+ const slugTailMatches = new Set();
542
+ if (!isFullPath) {
543
+ for (const [alias, canonical] of slugIndex) {
544
+ if (alias.slice(alias.lastIndexOf('#') + 1) === q) {
545
+ slugTailMatches.add(canonical.toLowerCase());
546
+ }
547
+ }
548
+ }
549
+ const literalTailMatches = new Set(isFullPath
550
+ ? []
551
+ : flat
552
+ .filter((s) => tailSegments(s.id).some((tail) => tail.toLowerCase() === q))
553
+ .map((s) => s.id.toLowerCase()));
554
+ const subsection = isFullPath
555
+ ? []
556
+ : flat
557
+ .filter((s) => {
558
+ if (seen.has(s.id))
559
+ return false;
560
+ const id = s.id.toLowerCase();
561
+ return literalTailMatches.size > 0
562
+ ? literalTailMatches.has(id)
563
+ : slugTailMatches.has(id);
564
+ })
565
+ .map((s) => ({ section: s, reason: 'section name match' }));
566
+ // Tier 2b: subsequence match — query segments are a subsequence of section id segments
567
+ // e.g. "Markdown#Resolution Rules" matches "markdown#Wiki Links#Resolution Rules"
568
+ // Also tries expanding the file part via the file index for short refs.
569
+ const seenSub = new Set([...seen, ...subsection.map((m) => m.section.id)]);
570
+ const qParts = q.split('#');
571
+ // Build query variants: original + file-index-expanded forms
572
+ const qVariants = [qParts];
573
+ if (qParts.length >= 2) {
574
+ const expanded = fileIndex.get(qParts[0]);
575
+ if (expanded) {
576
+ for (const exp of expanded) {
577
+ qVariants.push([exp.toLowerCase(), ...qParts.slice(1)]);
578
+ }
579
+ }
580
+ }
581
+ const subsequence = qParts.length >= 2
582
+ ? flat
583
+ .filter((s) => {
584
+ if (seenSub.has(s.id))
585
+ return false;
586
+ const sParts = s.id.toLowerCase().split('#');
587
+ return qVariants.some((variant) => {
588
+ if (sParts.length <= variant.length)
589
+ return false;
590
+ let qi = 0;
591
+ for (const sp of sParts) {
592
+ if (sp === variant[qi])
593
+ qi++;
594
+ if (qi === variant.length)
595
+ return true;
596
+ }
597
+ return false;
598
+ });
599
+ })
600
+ .map((s) => {
601
+ const skipped = s.id.split('#').length - qParts.length;
602
+ return {
603
+ section: s,
604
+ reason: `path match, ${skipped} intermediate ${skipped === 1 ? 'section' : 'sections'} skipped`,
605
+ };
606
+ })
607
+ : [];
608
+ // Tier 3: fuzzy match by edit distance on each segment tail and full id
609
+ const seenAll = new Set([
610
+ ...seenSub,
611
+ ...subsequence.map((m) => m.section.id),
612
+ ]);
613
+ const fuzzy = [];
614
+ // For full-path queries, extract the file and heading parts so we can
615
+ // fuzzy-match only the heading portion when the file part matches exactly.
616
+ // This prevents the shared file prefix from inflating similarity scores
617
+ // (e.g. "cli#locat" would otherwise fuzzy-match "cli#prompt").
618
+ const qHashIdx = normalized.indexOf('#');
619
+ const qFile = qHashIdx === -1 ? null : normalized.slice(0, qHashIdx).toLowerCase();
620
+ const qHeading = qHashIdx === -1 ? null : normalized.slice(qHashIdx + 1).toLowerCase();
621
+ for (const s of flat) {
622
+ if (seenAll.has(s.id))
623
+ continue;
624
+ const candidates = [s.id, ...tailSegments(s.id)];
625
+ let best = Infinity;
626
+ let bestCandidate = '';
627
+ for (const c of candidates) {
628
+ let d;
629
+ let maxLen;
630
+ const cl = c.toLowerCase();
631
+ const cHashIdx = cl.indexOf('#');
632
+ // When both query and candidate have # and their file parts match,
633
+ // compare only the heading portions to avoid file-prefix inflation
634
+ if (qFile && qHeading && cHashIdx !== -1) {
635
+ const cFile = cl.slice(0, cHashIdx);
636
+ const cHeading = cl.slice(cHashIdx + 1);
637
+ if (cFile === qFile) {
638
+ d = levenshtein(cHeading, qHeading);
639
+ maxLen = Math.max(cHeading.length, qHeading.length);
640
+ }
641
+ else {
642
+ d = levenshtein(cl, q);
643
+ maxLen = Math.max(c.length, q.length);
644
+ }
645
+ }
646
+ else {
647
+ d = levenshtein(cl, q);
648
+ maxLen = Math.max(c.length, q.length);
649
+ }
650
+ if (maxLen > 0 && d / maxLen <= MAX_DISTANCE_RATIO && d < best) {
651
+ best = d;
652
+ bestCandidate = c;
653
+ }
654
+ }
655
+ if (best < Infinity) {
656
+ fuzzy.push({ section: s, distance: best, matched: bestCandidate });
657
+ }
658
+ }
659
+ fuzzy.sort((a, b) => a.distance - b.distance);
660
+ const fuzzyMatches = fuzzy.map((f) => ({
661
+ section: f.section,
662
+ reason: f.matched.toLowerCase() === f.section.id.toLowerCase()
663
+ ? `fuzzy match, distance ${f.distance}`
664
+ : `fuzzy match on "${f.matched}", distance ${f.distance}`,
665
+ }));
666
+ // Sort results: shallower depth first, then fewer path segments
667
+ const sortKey = (s) => {
668
+ const pathDepth = (s.file.match(/\//g) || []).length;
669
+ return s.depth * 100 + pathDepth;
670
+ };
671
+ const sortedStems = [...stemMatches].sort((a, b) => sortKey(a.section) - sortKey(b.section));
672
+ return [
673
+ ...exactMatches,
674
+ ...sortedStems,
675
+ ...subsection,
676
+ ...subsequence,
677
+ ...fuzzyMatches,
678
+ ];
679
+ }
680
+ export function extractRefs(filePath, content, projectRoot, tree = parse(content)) {
681
+ const file = projectRoot
682
+ ? toPosix(relative(projectRoot, filePath)).replace(/\.md$/, '')
683
+ : basename(filePath, '.md');
684
+ const refs = [];
685
+ // Build a flat list of sections to determine enclosing section for each wiki link
686
+ const flat = [];
687
+ visit(tree, 'heading', (node) => {
688
+ flat.push({
689
+ id: '', // filled below
690
+ startLine: node.position.start.line,
691
+ });
692
+ });
693
+ // Re-derive ids using the same logic as parseSections
694
+ const stack = [];
695
+ let idx = 0;
696
+ visit(tree, 'heading', (node) => {
697
+ const heading = headingText(node);
698
+ const depth = node.depth;
699
+ while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
700
+ stack.pop();
701
+ }
702
+ const parent = stack.length > 0 ? stack[stack.length - 1] : null;
703
+ const id = parent ? `${parent.id}#${heading}` : `${file}#${heading}`;
704
+ flat[idx].id = id;
705
+ stack.push({ id, depth });
706
+ idx++;
707
+ });
708
+ visit(tree, 'wikiLink', (node) => {
709
+ const line = node.position.start.line;
710
+ // Find enclosing section: last heading with startLine <= link line
711
+ let fromSection = '';
712
+ for (const s of flat) {
713
+ if (s.startLine <= line) {
714
+ fromSection = s.id;
715
+ }
716
+ else {
717
+ break;
718
+ }
719
+ }
720
+ refs.push({
721
+ target: node.value,
722
+ fromSection,
723
+ file,
724
+ line,
725
+ });
726
+ });
727
+ return refs;
728
+ }
729
+ function isEscapedAt(value, index) {
730
+ let slashes = 0;
731
+ for (let i = index - 1; i >= 0 && value[i] === '\\'; i--)
732
+ slashes++;
733
+ return slashes % 2 === 1;
734
+ }
735
+ function closingBracket(value, open) {
736
+ let depth = 0;
737
+ for (let i = open; i < value.length; i++) {
738
+ if (isEscapedAt(value, i))
739
+ continue;
740
+ if (value[i] === '[') {
741
+ depth++;
742
+ }
743
+ else if (value[i] === ']') {
744
+ depth--;
745
+ if (depth === 0)
746
+ return i;
747
+ }
748
+ }
749
+ return -1;
750
+ }
751
+ /** Extract link destinations and undefined full/collapsed references. */
752
+ export function extractLinks(content, tree = parse(content)) {
753
+ const links = [];
754
+ visit(tree, ['link', 'image', 'definition'], (node) => {
755
+ const { url, type, position } = node;
756
+ links.push({ url, kind: type, line: position.start.line });
757
+ });
758
+ // CommonMark parses a reference with no matching definition as plain text,
759
+ // so it has no linkReference node to visit. Scan the authored syntax while
760
+ // excluding AST ranges where bracket text is data rather than prose.
761
+ const excluded = [];
762
+ visit(tree, [
763
+ 'code',
764
+ 'inlineCode',
765
+ 'html',
766
+ 'yaml',
767
+ 'link',
768
+ 'image',
769
+ 'definition',
770
+ 'linkReference',
771
+ 'imageReference',
772
+ 'wikiLink',
773
+ ], (node) => {
774
+ const start = node.position?.start.offset;
775
+ const end = node.position?.end.offset;
776
+ if (start !== undefined && end !== undefined) {
777
+ excluded.push({ start, end });
778
+ }
779
+ });
780
+ const overlapsExcluded = (start, end) => excluded.some((range) => start < range.end && end > range.start);
781
+ for (let open = 0; open < content.length; open++) {
782
+ if (content[open] !== '[' || isEscapedAt(content, open))
783
+ continue;
784
+ const labelEnd = closingBracket(content, open);
785
+ const identifierStart = labelEnd + 1;
786
+ if (labelEnd === -1 ||
787
+ content[identifierStart] !== '[' ||
788
+ isEscapedAt(content, identifierStart)) {
789
+ continue;
790
+ }
791
+ const identifierEnd = closingBracket(content, identifierStart);
792
+ if (identifierEnd === -1)
793
+ continue;
794
+ const isImage = open > 0 && content[open - 1] === '!' && !isEscapedAt(content, open - 1);
795
+ const start = isImage ? open - 1 : open;
796
+ const end = identifierEnd + 1;
797
+ if (overlapsExcluded(start, end)) {
798
+ open = identifierEnd;
799
+ continue;
800
+ }
801
+ const explicitIdentifier = content.slice(identifierStart + 1, identifierEnd);
802
+ const identifier = explicitIdentifier || content.slice(open + 1, labelEnd);
803
+ if (identifier.trim() === '') {
804
+ open = identifierEnd;
805
+ continue;
806
+ }
807
+ links.push({
808
+ identifier: identifier.trim(),
809
+ source: content.slice(start, end),
810
+ kind: isImage ? 'imageReference' : 'linkReference',
811
+ line: content.slice(0, start).split('\n').length,
812
+ });
813
+ open = identifierEnd;
814
+ }
815
+ return links.sort((a, b) => a.line - b.line);
816
+ }