lat.md 0.5.0 → 0.6.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.
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { basename, extname, join, relative } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { basename, dirname, extname, join, relative } from 'node:path';
3
4
  import { listLatticeFiles, loadAllSections, extractRefs, flattenSections, parseFrontmatter, parseSections, buildFileIndex, resolveRef, } from '../lattice.js';
4
5
  import { scanCodeRefs } from '../code-refs.js';
5
6
  import { walkEntries } from '../walk.js';
@@ -30,7 +31,50 @@ function countByExt(paths) {
30
31
  }
31
32
  return stats;
32
33
  }
34
+ /** Source file extensions recognized for code wiki links. */
35
+ const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.py']);
36
+ function isSourcePath(target) {
37
+ const hashIdx = target.indexOf('#');
38
+ const filePart = hashIdx === -1 ? target : target.slice(0, hashIdx);
39
+ const ext = extname(filePart);
40
+ return SOURCE_EXTS.has(ext);
41
+ }
42
+ /**
43
+ * Try resolving a wiki link target as a source code reference.
44
+ * Returns null if the reference is valid, or an error message string.
45
+ */
46
+ async function tryResolveSourceRef(target, projectRoot) {
47
+ if (!isSourcePath(target)) {
48
+ return `broken link [[${target}]] — no matching section found`;
49
+ }
50
+ const hashIdx = target.indexOf('#');
51
+ const filePart = hashIdx === -1 ? target : target.slice(0, hashIdx);
52
+ const symbolPart = hashIdx === -1 ? '' : target.slice(hashIdx + 1);
53
+ const absPath = join(projectRoot, filePart);
54
+ if (!existsSync(absPath)) {
55
+ return `broken link [[${target}]] — file "${filePart}" not found`;
56
+ }
57
+ if (!symbolPart) {
58
+ // File-only link with no symbol — valid as long as file exists
59
+ return null;
60
+ }
61
+ try {
62
+ const { resolveSourceSymbol } = await import('../source-parser.js');
63
+ const { found, error } = await resolveSourceSymbol(filePart, symbolPart, projectRoot);
64
+ if (error) {
65
+ return `broken link [[${target}]] — ${error}`;
66
+ }
67
+ if (!found) {
68
+ return `broken link [[${target}]] — symbol "${symbolPart}" not found in "${filePart}"`;
69
+ }
70
+ return null;
71
+ }
72
+ catch (err) {
73
+ return `broken link [[${target}]] — failed to parse "${filePart}": ${err instanceof Error ? err.message : String(err)}`;
74
+ }
75
+ }
33
76
  export async function checkMd(latticeDir) {
77
+ const projectRoot = dirname(latticeDir);
34
78
  const files = await listLatticeFiles(latticeDir);
35
79
  const allSections = await loadAllSections(latticeDir);
36
80
  const flat = flattenSections(allSections);
@@ -39,7 +83,7 @@ export async function checkMd(latticeDir) {
39
83
  const errors = [];
40
84
  for (const file of files) {
41
85
  const content = await readFile(file, 'utf-8');
42
- const refs = extractRefs(file, content, latticeDir);
86
+ const refs = extractRefs(file, content, projectRoot);
43
87
  const relPath = relative(process.cwd(), file);
44
88
  for (const ref of refs) {
45
89
  const { resolved, ambiguous, suggested } = resolveRef(ref.target, sectionIds, fileIndex);
@@ -52,19 +96,23 @@ export async function checkMd(latticeDir) {
52
96
  });
53
97
  }
54
98
  else if (!sectionIds.has(resolved.toLowerCase())) {
55
- errors.push({
56
- file: relPath,
57
- line: ref.line,
58
- target: ref.target,
59
- message: `broken link [[${ref.target}]] — no matching section found`,
60
- });
99
+ // Try resolving as a source code reference (e.g. [[src/foo.ts#bar]])
100
+ const sourceErr = await tryResolveSourceRef(ref.target, projectRoot);
101
+ if (sourceErr !== null) {
102
+ errors.push({
103
+ file: relPath,
104
+ line: ref.line,
105
+ target: ref.target,
106
+ message: sourceErr,
107
+ });
108
+ }
61
109
  }
62
110
  }
63
111
  }
64
112
  return { errors, files: countByExt(files) };
65
113
  }
66
114
  export async function checkCodeRefs(latticeDir) {
67
- const projectRoot = join(latticeDir, '..');
115
+ const projectRoot = dirname(latticeDir);
68
116
  const allSections = await loadAllSections(latticeDir);
69
117
  const flat = flattenSections(allSections);
70
118
  const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
@@ -98,7 +146,7 @@ export async function checkCodeRefs(latticeDir) {
98
146
  const fm = parseFrontmatter(content);
99
147
  if (!fm.requireCodeMention)
100
148
  continue;
101
- const sections = parseSections(file, content, latticeDir);
149
+ const sections = parseSections(file, content, projectRoot);
102
150
  const fileSections = flattenSections(sections);
103
151
  const leafSections = fileSections.filter((s) => s.children.length === 0);
104
152
  const relPath = relative(process.cwd(), file);
@@ -1,6 +1,7 @@
1
1
  import { type ChalkInstance } from 'chalk';
2
2
  export type CliContext = {
3
3
  latDir: string;
4
+ projectRoot: string;
4
5
  color: boolean;
5
6
  chalk: ChalkInstance;
6
7
  };
@@ -1,3 +1,4 @@
1
+ import { dirname } from 'node:path';
1
2
  import chalk from 'chalk';
2
3
  import { findLatticeDir } from '../lattice.js';
3
4
  export function resolveContext(opts) {
@@ -11,5 +12,6 @@ export function resolveContext(opts) {
11
12
  console.error(chalk.dim('Run `lat init` to create one.'));
12
13
  process.exit(1);
13
14
  }
14
- return { latDir, color, chalk };
15
+ const projectRoot = dirname(latDir);
16
+ return { latDir, projectRoot, color, chalk };
15
17
  }
@@ -8,5 +8,5 @@ export async function locateCmd(ctx, query) {
8
8
  console.error(ctx.chalk.red(`No sections matching "${stripped}" (no exact, substring, or fuzzy matches)`));
9
9
  process.exit(1);
10
10
  }
11
- console.log(formatResultList(`Sections matching "${stripped}":`, matches, ctx.latDir));
11
+ console.log(formatResultList(`Sections matching "${stripped}":`, matches, ctx.projectRoot));
12
12
  }
@@ -1,8 +1,8 @@
1
- import { relative } from 'node:path';
1
+ import { join, relative } from 'node:path';
2
2
  import { loadAllSections, findSections, } from '../lattice.js';
3
3
  const WIKI_LINK_RE = /\[\[([^\]]+)\]\]/g;
4
- function formatLocation(section, latDir) {
5
- const relPath = relative(process.cwd(), latDir + '/' + section.file + '.md');
4
+ function formatLocation(section, projectRoot) {
5
+ const relPath = relative(process.cwd(), join(projectRoot, section.filePath));
6
6
  return `${relPath}:${section.startLine}-${section.endLine}`;
7
7
  }
8
8
  export async function promptCmd(ctx, text) {
@@ -38,7 +38,8 @@ export async function promptCmd(ctx, text) {
38
38
  // Append context block as nested outliner
39
39
  output += '\n\n<lat-context>\n';
40
40
  for (const ref of resolved.values()) {
41
- const isExact = ref.best.reason === 'exact match';
41
+ const isExact = ref.best.reason === 'exact match' ||
42
+ ref.best.reason.startsWith('file stem expanded');
42
43
  const all = isExact ? [ref.best] : [ref.best, ...ref.alternatives];
43
44
  if (isExact) {
44
45
  output += `* \`[[${ref.target}]]\` is referring to:\n`;
@@ -49,7 +50,7 @@ export async function promptCmd(ctx, text) {
49
50
  for (const m of all) {
50
51
  const reason = isExact ? '' : ` (${m.reason})`;
51
52
  output += ` * [[${m.section.id}]]${reason}\n`;
52
- output += ` * ${formatLocation(m.section, ctx.latDir)}\n`;
53
+ output += ` * ${formatLocation(m.section, ctx.projectRoot)}\n`;
53
54
  if (m.section.body) {
54
55
  output += ` * ${m.section.body}\n`;
55
56
  }
@@ -1,5 +1,4 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
2
  import { listLatticeFiles, loadAllSections, findSections, extractRefs, flattenSections, buildFileIndex, resolveRef, } from '../lattice.js';
4
3
  import { formatResultList } from '../format.js';
5
4
  import { scanCodeRefs } from '../code-refs.js';
@@ -39,7 +38,7 @@ export async function refsCmd(ctx, query, scope) {
39
38
  const matchingFromSections = new Set();
40
39
  for (const file of files) {
41
40
  const content = await readFile(file, 'utf-8');
42
- const fileRefs = extractRefs(file, content, ctx.latDir);
41
+ const fileRefs = extractRefs(file, content, ctx.projectRoot);
43
42
  for (const ref of fileRefs) {
44
43
  const { resolved: refResolved } = resolveRef(ref.target, sectionIds, fileIndex);
45
44
  if (refResolved.toLowerCase() === targetId) {
@@ -55,8 +54,7 @@ export async function refsCmd(ctx, query, scope) {
55
54
  }
56
55
  }
57
56
  if (scope === 'code' || scope === 'md+code') {
58
- const projectRoot = join(ctx.latDir, '..');
59
- const { refs: codeRefs } = await scanCodeRefs(projectRoot);
57
+ const { refs: codeRefs } = await scanCodeRefs(ctx.projectRoot);
60
58
  for (const ref of codeRefs) {
61
59
  const { resolved: codeResolved } = resolveRef(ref.target, sectionIds, fileIndex);
62
60
  if (codeResolved.toLowerCase() === targetId) {
@@ -69,7 +67,7 @@ export async function refsCmd(ctx, query, scope) {
69
67
  process.exit(1);
70
68
  }
71
69
  if (mdMatches.length > 0) {
72
- console.log(formatResultList(`References to "${exactMatch.id}":`, mdMatches, ctx.latDir));
70
+ console.log(formatResultList(`References to "${exactMatch.id}":`, mdMatches, ctx.projectRoot));
73
71
  }
74
72
  if (codeLines.length > 0) {
75
73
  if (mdMatches.length > 0)
@@ -59,7 +59,7 @@ export async function searchCmd(ctx, query, opts) {
59
59
  .map((r) => byId.get(r.id))
60
60
  .filter((s) => !!s)
61
61
  .map((s) => ({ section: s, reason: 'semantic match' }));
62
- console.log(formatResultList(`Search results for "${query}":`, matched, ctx.latDir));
62
+ console.log(formatResultList(`Search results for "${query}":`, matched, ctx.projectRoot));
63
63
  }
64
64
  finally {
65
65
  await closeDb(db);
@@ -1,6 +1,6 @@
1
1
  import type { Section, SectionMatch } from './lattice.js';
2
2
  export declare function formatSectionId(id: string): string;
3
- export declare function formatSectionPreview(section: Section, latticeDir: string, opts?: {
3
+ export declare function formatSectionPreview(section: Section, projectRoot: string, opts?: {
4
4
  reason?: string;
5
5
  }): string;
6
- export declare function formatResultList(header: string, matches: SectionMatch[], latticeDir: string): string;
6
+ export declare function formatResultList(header: string, matches: SectionMatch[], projectRoot: string): string;
@@ -1,4 +1,4 @@
1
- import { relative } from 'node:path';
1
+ import { join, relative } from 'node:path';
2
2
  import chalk from 'chalk';
3
3
  export function formatSectionId(id) {
4
4
  const parts = id.split('#');
@@ -7,8 +7,8 @@ export function formatSectionId(id) {
7
7
  : chalk.dim(parts.slice(0, -1).join('#') + '#') +
8
8
  chalk.bold.white(parts[parts.length - 1]);
9
9
  }
10
- export function formatSectionPreview(section, latticeDir, opts) {
11
- const relPath = relative(process.cwd(), latticeDir + '/' + section.file + '.md');
10
+ export function formatSectionPreview(section, projectRoot, opts) {
11
+ const relPath = relative(process.cwd(), join(projectRoot, section.filePath));
12
12
  const kind = section.id.includes('#') ? 'Section' : 'File';
13
13
  const reasonSuffix = opts?.reason ? ' ' + chalk.dim(`(${opts.reason})`) : '';
14
14
  const lines = [
@@ -24,12 +24,12 @@ export function formatSectionPreview(section, latticeDir, opts) {
24
24
  }
25
25
  return lines.join('\n');
26
26
  }
27
- export function formatResultList(header, matches, latticeDir) {
27
+ export function formatResultList(header, matches, projectRoot) {
28
28
  const lines = ['', chalk.bold(header), ''];
29
29
  for (let i = 0; i < matches.length; i++) {
30
30
  if (i > 0)
31
31
  lines.push('');
32
- lines.push(formatSectionPreview(matches[i].section, latticeDir, {
32
+ lines.push(formatSectionPreview(matches[i].section, projectRoot, {
33
33
  reason: matches[i].reason,
34
34
  }));
35
35
  }
@@ -3,6 +3,7 @@ export type Section = {
3
3
  heading: string;
4
4
  depth: number;
5
5
  file: string;
6
+ filePath: string;
6
7
  children: Section[];
7
8
  startLine: number;
8
9
  endLine: number;
@@ -20,13 +21,18 @@ export type LatFrontmatter = {
20
21
  export declare function parseFrontmatter(content: string): LatFrontmatter;
21
22
  export declare function stripFrontmatter(content: string): string;
22
23
  export declare function findLatticeDir(from?: string): string | null;
24
+ export declare function findProjectRoot(from?: string): string | null;
23
25
  export declare function listLatticeFiles(latticeDir: string): Promise<string[]>;
24
- export declare function parseSections(filePath: string, content: string, latticeDir?: string): Section[];
26
+ export declare function parseSections(filePath: string, content: string, projectRoot?: string): Section[];
25
27
  export declare function loadAllSections(latticeDir: string): Promise<Section[]>;
26
28
  export declare function flattenSections(sections: Section[]): Section[];
27
29
  /**
28
- * Build an index mapping bare file stems to their full vault-relative paths.
29
- * Used by resolveRef to allow short references when a stem is unambiguous.
30
+ * Build an index mapping path suffixes to their full vault-relative paths.
31
+ * Used by resolveRef to allow short references when a suffix is unambiguous.
32
+ *
33
+ * For a file like `lat.md/guides/setup`, indexes both `guides/setup` and `setup`.
34
+ * This ensures backward-compatible short refs after the vault root moved to the
35
+ * project root (so section IDs now include the `lat.md/` prefix).
30
36
  */
31
37
  export declare function buildFileIndex(sections: Section[]): Map<string, string[]>;
32
38
  export type ResolveResult = {
@@ -50,4 +56,4 @@ export type SectionMatch = {
50
56
  reason: string;
51
57
  };
52
58
  export declare function findSections(sections: Section[], query: string): SectionMatch[];
53
- export declare function extractRefs(filePath: string, content: string, latticeDir?: string): Ref[];
59
+ export declare function extractRefs(filePath: string, content: string, projectRoot?: string): Ref[];
@@ -31,6 +31,10 @@ export function findLatticeDir(from) {
31
31
  dir = parent;
32
32
  }
33
33
  }
34
+ export function findProjectRoot(from) {
35
+ const latDir = findLatticeDir(from);
36
+ return latDir ? dirname(latDir) : null;
37
+ }
34
38
  export async function listLatticeFiles(latticeDir) {
35
39
  const entries = await walkEntries(latticeDir);
36
40
  return entries
@@ -64,11 +68,14 @@ function lastLine(content) {
64
68
  // If trailing newline, count doesn't include empty last line
65
69
  return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length;
66
70
  }
67
- export function parseSections(filePath, content, latticeDir) {
71
+ export function parseSections(filePath, content, projectRoot) {
68
72
  const tree = parse(stripFrontmatter(content));
69
- const file = latticeDir
70
- ? relative(latticeDir, filePath).replace(/\.md$/, '')
73
+ const file = projectRoot
74
+ ? relative(projectRoot, filePath).replace(/\.md$/, '')
71
75
  : basename(filePath, '.md');
76
+ const sectionFilePath = projectRoot
77
+ ? relative(projectRoot, filePath)
78
+ : basename(filePath);
72
79
  const roots = [];
73
80
  const stack = [];
74
81
  const flat = [];
@@ -87,6 +94,7 @@ export function parseSections(filePath, content, latticeDir) {
87
94
  heading,
88
95
  depth,
89
96
  file,
97
+ filePath: sectionFilePath,
90
98
  children: [],
91
99
  startLine,
92
100
  endLine: 0,
@@ -132,11 +140,12 @@ export function parseSections(filePath, content, latticeDir) {
132
140
  return roots;
133
141
  }
134
142
  export async function loadAllSections(latticeDir) {
143
+ const projectRoot = dirname(latticeDir);
135
144
  const files = await listLatticeFiles(latticeDir);
136
145
  const all = [];
137
146
  for (const file of files) {
138
147
  const content = await readFile(file, 'utf-8');
139
- all.push(...parseSections(file, content, latticeDir));
148
+ all.push(...parseSections(file, content, projectRoot));
140
149
  }
141
150
  return all;
142
151
  }
@@ -180,18 +189,26 @@ function tailSegments(id) {
180
189
  return tails;
181
190
  }
182
191
  /**
183
- * Build an index mapping bare file stems to their full vault-relative paths.
184
- * Used by resolveRef to allow short references when a stem is unambiguous.
192
+ * Build an index mapping path suffixes to their full vault-relative paths.
193
+ * Used by resolveRef to allow short references when a suffix is unambiguous.
194
+ *
195
+ * For a file like `lat.md/guides/setup`, indexes both `guides/setup` and `setup`.
196
+ * This ensures backward-compatible short refs after the vault root moved to the
197
+ * project root (so section IDs now include the `lat.md/` prefix).
185
198
  */
186
199
  export function buildFileIndex(sections) {
187
200
  const flat = flattenSections(sections);
188
201
  const index = new Map();
189
202
  for (const s of flat) {
190
- const stem = s.file.includes('/') ? s.file.split('/').pop() : null;
191
- if (stem) {
192
- if (!index.has(stem))
193
- index.set(stem, new Set());
194
- index.get(stem).add(s.file);
203
+ const parts = s.file.split('/');
204
+ // Index all trailing path suffixes (excluding the full path itself,
205
+ // which is handled by exact match). Keys are lowercase for
206
+ // case-insensitive lookup.
207
+ for (let i = 1; i < parts.length; i++) {
208
+ const suffix = parts.slice(i).join('/').toLowerCase();
209
+ if (!index.has(suffix))
210
+ index.set(suffix, new Set());
211
+ index.get(suffix).add(s.file);
195
212
  }
196
213
  }
197
214
  const result = new Map();
@@ -219,8 +236,10 @@ export function resolveRef(target, sectionIds, fileIndex) {
219
236
  const filePart = hashIdx === -1 ? target : target.slice(0, hashIdx);
220
237
  const rest = hashIdx === -1 ? '' : target.slice(hashIdx);
221
238
  // Try resolving the file part: either it's a full path or a bare stem
222
- const filePaths = fileIndex.has(filePart)
223
- ? fileIndex.get(filePart)
239
+ // File index keys are lowercase for case-insensitive lookup.
240
+ const lcFilePart = filePart.toLowerCase();
241
+ const filePaths = fileIndex.has(lcFilePart)
242
+ ? fileIndex.get(lcFilePart)
224
243
  : [filePart];
225
244
  if (filePaths.length === 1) {
226
245
  const fp = filePaths[0];
@@ -288,27 +307,44 @@ export function findSections(sections, query) {
288
307
  }));
289
308
  if (exactMatches.length > 0 && isFullPath)
290
309
  return exactMatches;
310
+ // Build file index early — used by both tier 1a and 1b
311
+ const fileIndex = buildFileIndex(sections);
291
312
  // Tier 1a: bare name matches file — return root sections of that file
313
+ // Also checks via file index (e.g. "dev-process" → "lat.md/dev-process")
292
314
  if (!isFullPath && exactMatches.length === 0) {
293
- const fileRoots = flat.filter((s) => s.file.toLowerCase() === q && !s.id.includes('#', s.file.length + 1));
294
- if (fileRoots.length > 0) {
295
- return fileRoots.map((s) => ({
296
- section: s,
297
- reason: 'exact match',
298
- }));
315
+ const matchFiles = new Set();
316
+ // Direct match
317
+ for (const s of flat) {
318
+ if (s.file.toLowerCase() === q &&
319
+ !s.id.includes('#', s.file.length + 1)) {
320
+ matchFiles.add(s.file);
321
+ }
322
+ }
323
+ // File index expansion (keys are lowercase)
324
+ const indexPaths = fileIndex.get(q) ?? [];
325
+ for (const p of indexPaths) {
326
+ matchFiles.add(p);
327
+ }
328
+ if (matchFiles.size > 0) {
329
+ const fileRoots = flat.filter((s) => matchFiles.has(s.file) && !s.id.includes('#', s.file.length + 1));
330
+ if (fileRoots.length > 0) {
331
+ return fileRoots.map((s) => ({
332
+ section: s,
333
+ reason: 'exact match',
334
+ }));
335
+ }
299
336
  }
300
337
  }
301
338
  // Tier 1b: file stem expansion
302
339
  // For bare names: "locate" → matches root section of "tests/locate.md"
303
340
  // For paths with #: "setup#Install" → expands to "guides/setup#Install"
304
- const fileIndex = buildFileIndex(sections);
305
341
  const stemMatches = [];
306
342
  if (isFullPath) {
307
343
  // Expand file stem in the file part of the query
308
344
  const hashIdx = normalized.indexOf('#');
309
345
  const filePart = normalized.slice(0, hashIdx);
310
346
  const rest = normalized.slice(hashIdx);
311
- const stemPaths = fileIndex.get(filePart) ?? [];
347
+ const stemPaths = fileIndex.get(filePart.toLowerCase()) ?? [];
312
348
  // Also try filePart as a direct file path (for root-level files not in index)
313
349
  const allPaths = stemPaths.length > 0 ? stemPaths : filePart ? [filePart] : [];
314
350
  for (const p of allPaths) {
@@ -343,8 +379,8 @@ export function findSections(sections, query) {
343
379
  return [...exactMatches, ...stemMatches];
344
380
  }
345
381
  else {
346
- // Bare name: match root sections of files via stem index
347
- const paths = fileIndex.get(normalized) ?? [];
382
+ // Bare name: match root sections of files via stem index (keys lowercase)
383
+ const paths = fileIndex.get(q) ?? [];
348
384
  for (const p of paths) {
349
385
  for (const s of flat) {
350
386
  if (exact.includes(s))
@@ -373,24 +409,37 @@ export function findSections(sections, query) {
373
409
  .map((s) => ({ section: s, reason: 'section name match' }));
374
410
  // Tier 2b: subsequence match — query segments are a subsequence of section id segments
375
411
  // e.g. "Markdown#Resolution Rules" matches "markdown#Wiki Links#Resolution Rules"
412
+ // Also tries expanding the file part via the file index for short refs.
376
413
  const seenSub = new Set([...seen, ...subsection.map((m) => m.section.id)]);
377
414
  const qParts = q.split('#');
415
+ // Build query variants: original + file-index-expanded forms
416
+ const qVariants = [qParts];
417
+ if (qParts.length >= 2) {
418
+ const expanded = fileIndex.get(qParts[0]);
419
+ if (expanded) {
420
+ for (const exp of expanded) {
421
+ qVariants.push([exp.toLowerCase(), ...qParts.slice(1)]);
422
+ }
423
+ }
424
+ }
378
425
  const subsequence = qParts.length >= 2
379
426
  ? flat
380
427
  .filter((s) => {
381
428
  if (seenSub.has(s.id))
382
429
  return false;
383
430
  const sParts = s.id.toLowerCase().split('#');
384
- if (sParts.length <= qParts.length)
431
+ return qVariants.some((variant) => {
432
+ if (sParts.length <= variant.length)
433
+ return false;
434
+ let qi = 0;
435
+ for (const sp of sParts) {
436
+ if (sp === variant[qi])
437
+ qi++;
438
+ if (qi === variant.length)
439
+ return true;
440
+ }
385
441
  return false;
386
- let qi = 0;
387
- for (const sp of sParts) {
388
- if (sp === qParts[qi])
389
- qi++;
390
- if (qi === qParts.length)
391
- return true;
392
- }
393
- return false;
442
+ });
394
443
  })
395
444
  .map((s) => {
396
445
  const skipped = s.id.split('#').length - qParts.length;
@@ -472,10 +521,10 @@ export function findSections(sections, query) {
472
521
  ...fuzzyMatches,
473
522
  ];
474
523
  }
475
- export function extractRefs(filePath, content, latticeDir) {
524
+ export function extractRefs(filePath, content, projectRoot) {
476
525
  const tree = parse(stripFrontmatter(content));
477
- const file = latticeDir
478
- ? relative(latticeDir, filePath).replace(/\.md$/, '')
526
+ const file = projectRoot
527
+ ? relative(projectRoot, filePath).replace(/\.md$/, '')
479
528
  : basename(filePath, '.md');
480
529
  const refs = [];
481
530
  // Build a flat list of sections to determine enclosing section for each wiki link
@@ -1,14 +1,13 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { z } from 'zod';
4
- import { relative } from 'node:path';
4
+ import { dirname, join, relative } from 'node:path';
5
5
  import { findLatticeDir, loadAllSections, findSections, flattenSections, buildFileIndex, resolveRef, extractRefs, listLatticeFiles, } from '../lattice.js';
6
6
  import { scanCodeRefs } from '../code-refs.js';
7
7
  import { checkMd, checkCodeRefs, checkIndex } from '../cli/check.js';
8
8
  import { readFile } from 'node:fs/promises';
9
- import { join } from 'node:path';
10
- function formatSection(s, latDir) {
11
- const relPath = relative(process.cwd(), latDir + '/' + s.file + '.md');
9
+ function formatSection(s, projectRoot) {
10
+ const relPath = relative(process.cwd(), join(projectRoot, s.filePath));
12
11
  const kind = s.id.includes('#') ? 'Section' : 'File';
13
12
  const lines = [
14
13
  `* ${kind}: [[${s.id}]]`,
@@ -20,12 +19,13 @@ function formatSection(s, latDir) {
20
19
  }
21
20
  return lines.join('\n');
22
21
  }
23
- function formatMatches(header, matches, latDir) {
22
+ function formatMatches(header, matches, projectRoot) {
24
23
  const lines = [header, ''];
25
24
  for (let i = 0; i < matches.length; i++) {
26
25
  if (i > 0)
27
26
  lines.push('');
28
- lines.push(formatSection(matches[i].section, latDir) + ` (${matches[i].reason})`);
27
+ lines.push(formatSection(matches[i].section, projectRoot) +
28
+ ` (${matches[i].reason})`);
29
29
  }
30
30
  return lines.join('\n');
31
31
  }
@@ -35,6 +35,7 @@ export async function startMcpServer() {
35
35
  process.stderr.write('No lat.md directory found\n');
36
36
  process.exit(1);
37
37
  }
38
+ const projectRoot = dirname(latDir);
38
39
  const server = new McpServer({
39
40
  name: 'lat',
40
41
  version: '1.0.0',
@@ -56,7 +57,7 @@ export async function startMcpServer() {
56
57
  content: [
57
58
  {
58
59
  type: 'text',
59
- text: formatMatches(`Sections matching "${query}":`, matches, latDir),
60
+ text: formatMatches(`Sections matching "${query}":`, matches, projectRoot),
60
61
  },
61
62
  ],
62
63
  };
@@ -117,7 +118,7 @@ export async function startMcpServer() {
117
118
  content: [
118
119
  {
119
120
  type: 'text',
120
- text: formatMatches(`Search results for "${query}":`, matched, latDir),
121
+ text: formatMatches(`Search results for "${query}":`, matched, projectRoot),
121
122
  },
122
123
  ],
123
124
  };
@@ -165,7 +166,8 @@ export async function startMcpServer() {
165
166
  });
166
167
  output += '\n\n<lat-context>\n';
167
168
  for (const ref of resolved.values()) {
168
- const isExact = ref.best.reason === 'exact match';
169
+ const isExact = ref.best.reason === 'exact match' ||
170
+ ref.best.reason.startsWith('file stem expanded');
169
171
  const all = isExact ? [ref.best] : [ref.best, ...ref.alternatives];
170
172
  if (isExact) {
171
173
  output += `* [[${ref.target}]] is referring to:\n`;
@@ -175,7 +177,7 @@ export async function startMcpServer() {
175
177
  }
176
178
  for (const m of all) {
177
179
  const reason = isExact ? '' : ` (${m.reason})`;
178
- const relPath = relative(process.cwd(), latDir + '/' + m.section.file + '.md');
180
+ const relPath = relative(process.cwd(), join(projectRoot, m.section.filePath));
179
181
  output += ` * [[${m.section.id}]]${reason}\n`;
180
182
  output += ` * ${relPath}:${m.section.startLine}-${m.section.endLine}\n`;
181
183
  if (m.section.body) {
@@ -259,7 +261,7 @@ export async function startMcpServer() {
259
261
  const matchingFromSections = new Set();
260
262
  for (const file of files) {
261
263
  const content = await readFile(file, 'utf-8');
262
- const fileRefs = extractRefs(file, content, latDir);
264
+ const fileRefs = extractRefs(file, content, projectRoot);
263
265
  for (const ref of fileRefs) {
264
266
  const { resolved: refResolved } = resolveRef(ref.target, sectionIds, fileIndex);
265
267
  if (refResolved.toLowerCase() === targetId) {
@@ -275,7 +277,6 @@ export async function startMcpServer() {
275
277
  }
276
278
  }
277
279
  if (scope === 'code' || scope === 'md+code') {
278
- const projectRoot = join(latDir, '..');
279
280
  const { refs: codeRefs } = await scanCodeRefs(projectRoot);
280
281
  for (const ref of codeRefs) {
281
282
  const { resolved: codeResolved } = resolveRef(ref.target, sectionIds, fileIndex);
@@ -296,7 +297,7 @@ export async function startMcpServer() {
296
297
  }
297
298
  const parts = [];
298
299
  if (mdMatches.length > 0) {
299
- parts.push(formatMatches(`References to "${exactMatch.id}":`, mdMatches, latDir));
300
+ parts.push(formatMatches(`References to "${exactMatch.id}":`, mdMatches, projectRoot));
300
301
  }
301
302
  if (codeLines.length > 0) {
302
303
  parts.push('Code references:\n' + codeLines.map((l) => `* ${l}`).join('\n'));
@@ -1,24 +1,25 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
3
+ import { dirname, join } from 'node:path';
4
4
  import { loadAllSections, flattenSections } from '../lattice.js';
5
5
  import { embed } from './embeddings.js';
6
6
  function hashContent(text) {
7
7
  return createHash('sha256').update(text).digest('hex');
8
8
  }
9
- async function sectionContent(section, latDir) {
10
- const filePath = join(latDir, section.file + '.md');
9
+ async function sectionContent(section, projectRoot) {
10
+ const filePath = join(projectRoot, section.filePath);
11
11
  const content = await readFile(filePath, 'utf-8');
12
12
  const lines = content.split('\n');
13
13
  return lines.slice(section.startLine - 1, section.endLine).join('\n');
14
14
  }
15
15
  export async function indexSections(latDir, db, provider, key) {
16
+ const projectRoot = dirname(latDir);
16
17
  const allSections = await loadAllSections(latDir);
17
18
  const flat = flattenSections(allSections);
18
19
  // Build current state: id -> { section, content, hash }
19
20
  const current = new Map();
20
21
  for (const s of flat) {
21
- const text = await sectionContent(s, latDir);
22
+ const text = await sectionContent(s, projectRoot);
22
23
  current.set(s.id, { section: s, content: text, hash: hashContent(text) });
23
24
  }
24
25
  // Get existing hashes from DB
@@ -0,0 +1,23 @@
1
+ import type { Section } from './lattice.js';
2
+ export type SourceSymbol = {
3
+ name: string;
4
+ kind: 'function' | 'class' | 'const' | 'type' | 'interface' | 'method' | 'variable';
5
+ parent?: string;
6
+ startLine: number;
7
+ endLine: number;
8
+ signature: string;
9
+ };
10
+ export declare function parseSourceSymbols(filePath: string, content: string): Promise<SourceSymbol[]>;
11
+ /**
12
+ * Check whether a source file path (relative to projectRoot) has a given symbol.
13
+ * Used by lat check to validate source code wiki links lazily.
14
+ */
15
+ export declare function resolveSourceSymbol(filePath: string, symbolPath: string, projectRoot: string): Promise<{
16
+ found: boolean;
17
+ symbols: SourceSymbol[];
18
+ error?: string;
19
+ }>;
20
+ /**
21
+ * Convert source symbols to Section objects for uniform handling.
22
+ */
23
+ export declare function sourceSymbolsToSections(symbols: SourceSymbol[], filePath: string): Section[];
@@ -0,0 +1,333 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { Parser, Language, } from 'web-tree-sitter';
5
+ // Lazy singleton for the parser
6
+ let parserReady = null;
7
+ let parserInstance = null;
8
+ const languages = new Map();
9
+ function wasmDir() {
10
+ const require = createRequire(import.meta.url);
11
+ const pkgPath = require.resolve('@repomix/tree-sitter-wasms/package.json');
12
+ return join(dirname(pkgPath), 'out');
13
+ }
14
+ async function ensureParser() {
15
+ if (!parserReady) {
16
+ parserReady = Parser.init();
17
+ }
18
+ await parserReady;
19
+ if (!parserInstance) {
20
+ parserInstance = new Parser();
21
+ }
22
+ return parserInstance;
23
+ }
24
+ async function getLanguage(ext) {
25
+ const grammarMap = {
26
+ '.ts': 'tree-sitter-typescript.wasm',
27
+ '.tsx': 'tree-sitter-tsx.wasm',
28
+ '.js': 'tree-sitter-javascript.wasm',
29
+ '.jsx': 'tree-sitter-javascript.wasm',
30
+ '.py': 'tree-sitter-python.wasm',
31
+ };
32
+ const wasmFile = grammarMap[ext];
33
+ if (!wasmFile)
34
+ return null;
35
+ // Ensure WASM runtime is initialized before loading languages
36
+ await ensureParser();
37
+ if (!languages.has(wasmFile)) {
38
+ const wasmPath = join(wasmDir(), wasmFile);
39
+ const lang = await Language.load(wasmPath);
40
+ languages.set(wasmFile, lang);
41
+ }
42
+ return languages.get(wasmFile);
43
+ }
44
+ function extractName(node) {
45
+ const nameNode = node.childForFieldName('name');
46
+ return nameNode ? nameNode.text : null;
47
+ }
48
+ function extractTsSymbols(tree) {
49
+ const symbols = [];
50
+ const root = tree.rootNode;
51
+ for (let i = 0; i < root.childCount; i++) {
52
+ let node = root.child(i);
53
+ // Unwrap export_statement to get the inner declaration
54
+ const isExport = node.type === 'export_statement';
55
+ if (isExport) {
56
+ const inner = node.namedChildren.find((c) => c.type === 'function_declaration' ||
57
+ c.type === 'class_declaration' ||
58
+ c.type === 'lexical_declaration' ||
59
+ c.type === 'type_alias_declaration' ||
60
+ c.type === 'interface_declaration' ||
61
+ c.type === 'abstract_class_declaration');
62
+ if (inner)
63
+ node = inner;
64
+ }
65
+ const startLine = node.startPosition.row + 1;
66
+ const endLine = node.endPosition.row + 1;
67
+ if (node.type === 'function_declaration' ||
68
+ node.type === 'generator_function_declaration') {
69
+ const name = extractName(node);
70
+ if (name) {
71
+ symbols.push({
72
+ name,
73
+ kind: 'function',
74
+ startLine,
75
+ endLine,
76
+ signature: firstLine(node.text),
77
+ });
78
+ }
79
+ }
80
+ else if (node.type === 'class_declaration' ||
81
+ node.type === 'abstract_class_declaration') {
82
+ const name = extractName(node);
83
+ if (name) {
84
+ symbols.push({
85
+ name,
86
+ kind: 'class',
87
+ startLine,
88
+ endLine,
89
+ signature: firstLine(node.text),
90
+ });
91
+ // Extract methods
92
+ const body = node.childForFieldName('body');
93
+ if (body) {
94
+ extractClassMethods(body, name, symbols);
95
+ }
96
+ }
97
+ }
98
+ else if (node.type === 'lexical_declaration') {
99
+ // const/let declarations
100
+ for (const decl of node.namedChildren) {
101
+ if (decl.type === 'variable_declarator') {
102
+ const name = extractName(decl);
103
+ if (name) {
104
+ symbols.push({
105
+ name,
106
+ kind: 'const',
107
+ startLine,
108
+ endLine,
109
+ signature: firstLine(node.text),
110
+ });
111
+ }
112
+ }
113
+ }
114
+ }
115
+ else if (node.type === 'type_alias_declaration') {
116
+ const name = extractName(node);
117
+ if (name) {
118
+ symbols.push({
119
+ name,
120
+ kind: 'type',
121
+ startLine,
122
+ endLine,
123
+ signature: firstLine(node.text),
124
+ });
125
+ }
126
+ }
127
+ else if (node.type === 'interface_declaration') {
128
+ const name = extractName(node);
129
+ if (name) {
130
+ symbols.push({
131
+ name,
132
+ kind: 'interface',
133
+ startLine,
134
+ endLine,
135
+ signature: firstLine(node.text),
136
+ });
137
+ }
138
+ }
139
+ }
140
+ return symbols;
141
+ }
142
+ function extractClassMethods(body, className, symbols) {
143
+ for (let i = 0; i < body.namedChildCount; i++) {
144
+ const member = body.namedChild(i);
145
+ if (member.type === 'method_definition' ||
146
+ member.type === 'public_field_definition') {
147
+ const name = extractName(member);
148
+ if (name) {
149
+ symbols.push({
150
+ name,
151
+ kind: 'method',
152
+ parent: className,
153
+ startLine: member.startPosition.row + 1,
154
+ endLine: member.endPosition.row + 1,
155
+ signature: firstLine(member.text),
156
+ });
157
+ }
158
+ }
159
+ }
160
+ }
161
+ function extractPySymbols(tree) {
162
+ const symbols = [];
163
+ const root = tree.rootNode;
164
+ for (let i = 0; i < root.childCount; i++) {
165
+ const node = root.child(i);
166
+ const startLine = node.startPosition.row + 1;
167
+ const endLine = node.endPosition.row + 1;
168
+ if (node.type === 'function_definition') {
169
+ const name = extractName(node);
170
+ if (name) {
171
+ symbols.push({
172
+ name,
173
+ kind: 'function',
174
+ startLine,
175
+ endLine,
176
+ signature: firstLine(node.text),
177
+ });
178
+ }
179
+ }
180
+ else if (node.type === 'class_definition') {
181
+ const name = extractName(node);
182
+ if (name) {
183
+ symbols.push({
184
+ name,
185
+ kind: 'class',
186
+ startLine,
187
+ endLine,
188
+ signature: firstLine(node.text),
189
+ });
190
+ // Extract methods
191
+ const body = node.childForFieldName('body');
192
+ if (body) {
193
+ for (let j = 0; j < body.namedChildCount; j++) {
194
+ const member = body.namedChild(j);
195
+ if (member.type === 'function_definition') {
196
+ const methodName = extractName(member);
197
+ if (methodName) {
198
+ symbols.push({
199
+ name: methodName,
200
+ kind: 'method',
201
+ parent: name,
202
+ startLine: member.startPosition.row + 1,
203
+ endLine: member.endPosition.row + 1,
204
+ signature: firstLine(member.text),
205
+ });
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }
211
+ }
212
+ else if (node.type === 'expression_statement' &&
213
+ node.namedChildCount === 1 &&
214
+ node.namedChild(0).type === 'assignment') {
215
+ // Top-level assignment: FOO = ...
216
+ const assign = node.namedChild(0);
217
+ const left = assign.childForFieldName('left');
218
+ if (left && left.type === 'identifier') {
219
+ symbols.push({
220
+ name: left.text,
221
+ kind: 'variable',
222
+ startLine,
223
+ endLine,
224
+ signature: firstLine(node.text),
225
+ });
226
+ }
227
+ }
228
+ }
229
+ return symbols;
230
+ }
231
+ function firstLine(text) {
232
+ const nl = text.indexOf('\n');
233
+ return nl === -1 ? text : text.slice(0, nl);
234
+ }
235
+ export async function parseSourceSymbols(filePath, content) {
236
+ const ext = filePath.match(/\.[^.]+$/)?.[0] ?? '';
237
+ const lang = await getLanguage(ext);
238
+ if (!lang)
239
+ return [];
240
+ const p = await ensureParser();
241
+ p.setLanguage(lang);
242
+ const tree = p.parse(content);
243
+ if (!tree)
244
+ return [];
245
+ if (ext === '.py') {
246
+ return extractPySymbols(tree);
247
+ }
248
+ return extractTsSymbols(tree);
249
+ }
250
+ /**
251
+ * Check whether a source file path (relative to projectRoot) has a given symbol.
252
+ * Used by lat check to validate source code wiki links lazily.
253
+ */
254
+ export async function resolveSourceSymbol(filePath, symbolPath, projectRoot) {
255
+ const absPath = join(projectRoot, filePath);
256
+ let content;
257
+ try {
258
+ content = readFileSync(absPath, 'utf-8');
259
+ }
260
+ catch {
261
+ return { found: false, symbols: [] };
262
+ }
263
+ let symbols;
264
+ try {
265
+ symbols = await parseSourceSymbols(filePath, content);
266
+ }
267
+ catch (err) {
268
+ return {
269
+ found: false,
270
+ symbols: [],
271
+ error: `failed to parse "${filePath}": ${err instanceof Error ? err.message : String(err)}`,
272
+ };
273
+ }
274
+ const parts = symbolPath.split('#');
275
+ if (parts.length === 1) {
276
+ // Simple symbol: getConfigDir
277
+ const found = symbols.some((s) => s.name === parts[0] && !s.parent);
278
+ return { found, symbols };
279
+ }
280
+ if (parts.length === 2) {
281
+ // Nested symbol: MyClass#myMethod
282
+ const found = symbols.some((s) => s.name === parts[1] && s.parent === parts[0]);
283
+ return { found, symbols };
284
+ }
285
+ return { found: false, symbols };
286
+ }
287
+ /**
288
+ * Convert source symbols to Section objects for uniform handling.
289
+ */
290
+ export function sourceSymbolsToSections(symbols, filePath) {
291
+ const sections = [];
292
+ const classMap = new Map();
293
+ for (const sym of symbols) {
294
+ if (sym.parent)
295
+ continue; // Handle methods after their class
296
+ const section = {
297
+ id: `${filePath}#${sym.name}`,
298
+ heading: sym.name,
299
+ depth: 1,
300
+ file: filePath,
301
+ filePath,
302
+ children: [],
303
+ startLine: sym.startLine,
304
+ endLine: sym.endLine,
305
+ body: sym.signature,
306
+ };
307
+ sections.push(section);
308
+ if (sym.kind === 'class') {
309
+ classMap.set(sym.name, section);
310
+ }
311
+ }
312
+ // Add methods as children
313
+ for (const sym of symbols) {
314
+ if (!sym.parent)
315
+ continue;
316
+ const parentSection = classMap.get(sym.parent);
317
+ if (!parentSection)
318
+ continue;
319
+ const section = {
320
+ id: `${filePath}#${sym.parent}#${sym.name}`,
321
+ heading: sym.name,
322
+ depth: 2,
323
+ file: filePath,
324
+ filePath,
325
+ children: [],
326
+ startLine: sym.startLine,
327
+ endLine: sym.endLine,
328
+ body: sym.signature,
329
+ };
330
+ parentSection.children.push(section);
331
+ }
332
+ return sections;
333
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lat.md",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A knowledge graph for your codebase, written in markdown",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.30.2",
@@ -50,6 +50,7 @@
50
50
  "@folder/xdg": "^4.0.1",
51
51
  "@libsql/client": "^0.17.0",
52
52
  "@modelcontextprotocol/sdk": "^1.27.1",
53
+ "@repomix/tree-sitter-wasms": "^0.1.16",
53
54
  "chalk": "^5.6.2",
54
55
  "commander": "^14.0.3",
55
56
  "ignore-walk": "^8.0.0",
@@ -58,6 +59,7 @@
58
59
  "remark-stringify": "^11.0.0",
59
60
  "unified": "^11.0.0",
60
61
  "unist-util-visit": "^5.0.0",
62
+ "web-tree-sitter": "^0.26.6",
61
63
  "zod": "^4.3.6"
62
64
  }
63
65
  }
@@ -33,8 +33,9 @@ If `lat search` fails because no API key is configured, explain to the user that
33
33
 
34
34
  # Syntax primer
35
35
 
36
- - **Section ids**: `path/to/file#Heading#SubHeading` — full form uses vault-relative path (e.g. `tests/search#RAG Replay Tests`). Short form uses bare file name when unique (e.g. `search#RAG Replay Tests`, `cli#search#Indexing`).
37
- - **Wiki links**: `[[target]]` or `[[target|alias]]` — cross-references between sections
36
+ - **Section ids**: `lat.md/path/to/file#Heading#SubHeading` — full form uses project-root-relative path (e.g. `lat.md/tests/search#RAG Replay Tests`). Short form uses bare file name when unique (e.g. `search#RAG Replay Tests`, `cli#search#Indexing`).
37
+ - **Wiki links**: `[[target]]` or `[[target|alias]]` — cross-references between sections. Can also reference source code: `[[src/foo.ts#myFunction]]`.
38
+ - **Source code links**: Wiki links in `lat.md/` files can reference functions, classes, constants, and methods in TypeScript/JavaScript/Python files. Use the full path: `[[src/config.ts#getConfigDir]]`, `[[src/server.ts#App#listen]]` (class method), `[[lib/utils.py#parse_args]]`. `lat check` validates these exist.
38
39
  - **Code refs**: `// @lat: [[section-id]]` (JS/TS) or `# @lat: [[section-id]]` (Python) — ties source code to concepts
39
40
 
40
41
  # Test specs
@@ -31,8 +31,9 @@ If `lat_search` fails because `LAT_LLM_KEY` is not set, explain to the user that
31
31
 
32
32
  # Syntax primer
33
33
 
34
- - **Section ids**: `path/to/file#Heading#SubHeading` — full form uses vault-relative path (e.g. `tests/search#RAG Replay Tests`). Short form uses bare file name when unique (e.g. `search#RAG Replay Tests`, `cli#search#Indexing`).
35
- - **Wiki links**: `[[target]]` or `[[target|alias]]` — cross-references between sections
34
+ - **Section ids**: `lat.md/path/to/file#Heading#SubHeading` — full form uses project-root-relative path (e.g. `lat.md/tests/search#RAG Replay Tests`). Short form uses bare file name when unique (e.g. `search#RAG Replay Tests`, `cli#search#Indexing`).
35
+ - **Wiki links**: `[[target]]` or `[[target|alias]]` — cross-references between sections. Can also reference source code: `[[src/foo.ts#myFunction]]`.
36
+ - **Source code links**: Wiki links in `lat.md/` files can reference functions, classes, constants, and methods in TypeScript/JavaScript/Python files. Use the full path: `[[src/config.ts#getConfigDir]]`, `[[src/server.ts#App#listen]]` (class method), `[[lib/utils.py#parse_args]]`. `lat check` validates these exist.
36
37
  - **Code refs**: `// @lat: [[section-id]]` (JS/TS) or `# @lat: [[section-id]]` (Python) — ties source code to concepts
37
38
 
38
39
  # Test specs