lat.md 0.4.3 → 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);
@@ -127,19 +175,26 @@ function immediateEntries(walkedPaths) {
127
175
  }
128
176
  return [...entries].sort();
129
177
  }
130
- /** Parse bullet items from an index file. Matches `- **name** — description` */
178
+ /** Parse bullet items from an index file. Matches `- [[name]] — description` */
131
179
  function parseIndexEntries(content) {
132
180
  const names = new Set();
133
- const re = /^- \*\*(.+?)\*\*/gm;
181
+ const re = /^- \[\[([^\]]+?)(?:\|[^\]]+)?\]\]/gm;
134
182
  let match;
135
183
  while ((match = re.exec(content)) !== null) {
136
184
  names.add(match[1]);
137
185
  }
138
186
  return names;
139
187
  }
188
+ /**
189
+ * Convert a filesystem entry name to its wiki link stem.
190
+ * Strips `.md` extension from files; directories stay as-is.
191
+ */
192
+ function entryToStem(name) {
193
+ return name.endsWith('.md') ? name.slice(0, -3) : name;
194
+ }
140
195
  /** Generate a bullet-list snippet for the given entry names. */
141
196
  function indexSnippet(entries) {
142
- return entries.map((e) => `- **${e}** — <describe>`).join('\n');
197
+ return entries.map((e) => `- [[${entryToStem(e)}]] — <describe>`).join('\n');
143
198
  }
144
199
  export async function checkIndex(latticeDir) {
145
200
  const errors = [];
@@ -183,12 +238,16 @@ export async function checkIndex(latticeDir) {
183
238
  });
184
239
  continue;
185
240
  }
186
- // Parse existing entries and validate
241
+ // Parse existing entries and validate.
242
+ // Listed entries are wiki link stems (no .md extension).
243
+ // Children are filesystem names (with .md for files, bare for dirs).
187
244
  const listed = parseIndexEntries(content);
245
+ const childStems = new Set(children.map(entryToStem));
246
+ const stemToChild = new Map(children.map((c) => [entryToStem(c), c]));
188
247
  const relDir = dir === '' ? basename(latticeDir) + '/' : dir + '/';
189
248
  const missing = [];
190
249
  for (const child of children) {
191
- if (!listed.has(child)) {
250
+ if (!listed.has(entryToStem(child))) {
192
251
  missing.push(child);
193
252
  }
194
253
  }
@@ -199,11 +258,12 @@ export async function checkIndex(latticeDir) {
199
258
  snippet: indexSnippet(missing),
200
259
  });
201
260
  }
261
+ const indexStem = entryToStem(indexFileName);
202
262
  for (const name of listed) {
203
- if (!children.includes(name) && name !== indexFileName) {
263
+ if (!childStems.has(name) && name !== indexStem) {
204
264
  errors.push({
205
265
  dir: relDir,
206
- message: `"${indexRelPath}" lists "${name}" but it does not exist`,
266
+ message: `"${indexRelPath}" lists "[[${name}]]" but it does not exist`,
207
267
  });
208
268
  }
209
269
  }
@@ -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) {
@@ -8,7 +9,9 @@ export function resolveContext(opts) {
8
9
  const latDir = findLatticeDir(opts.dir) ?? '';
9
10
  if (!latDir) {
10
11
  console.error(chalk.red('No lat.md directory found'));
12
+ console.error(chalk.dim('Run `lat init` to create one.'));
11
13
  process.exit(1);
12
14
  }
13
- return { latDir, color, chalk };
15
+ const projectRoot = dirname(latDir);
16
+ return { latDir, projectRoot, color, chalk };
14
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 = [];
@@ -81,12 +88,13 @@ export function parseSections(filePath, content, latticeDir) {
81
88
  stack.pop();
82
89
  }
83
90
  const parent = stack.length > 0 ? stack[stack.length - 1] : null;
84
- const id = parent ? `${parent.id}#${heading}` : file;
91
+ const id = parent ? `${parent.id}#${heading}` : `${file}#${heading}`;
85
92
  const section = {
86
93
  id,
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();
@@ -218,17 +235,41 @@ export function resolveRef(target, sectionIds, fileIndex) {
218
235
  const hashIdx = target.indexOf('#');
219
236
  const filePart = hashIdx === -1 ? target : target.slice(0, hashIdx);
220
237
  const rest = hashIdx === -1 ? '' : target.slice(hashIdx);
221
- const candidates = fileIndex.get(filePart) ?? [];
222
- if (candidates.length === 1) {
223
- const expanded = candidates[0] + rest;
238
+ // Try resolving the file part: either it's a full path or a bare stem
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)
243
+ : [filePart];
244
+ if (filePaths.length === 1) {
245
+ const fp = filePaths[0];
246
+ const expanded = fp + rest;
224
247
  if (sectionIds.has(expanded.toLowerCase())) {
225
248
  return { resolved: expanded, ambiguous: null, suggested: null };
226
249
  }
250
+ // Try inserting root headings between file and rest.
251
+ // Handles Obsidian-style file#heading refs where the h1 is implicit.
252
+ const rootHeadings = findRootHeadings(fp, sectionIds);
253
+ for (const h1 of rootHeadings) {
254
+ const withRoot = rest ? `${fp}#${h1}${rest}` : `${fp}#${h1}`;
255
+ if (sectionIds.has(withRoot.toLowerCase())) {
256
+ return { resolved: withRoot, ambiguous: null, suggested: null };
257
+ }
258
+ }
227
259
  }
228
- else if (candidates.length > 1) {
260
+ else if (filePaths.length > 1) {
229
261
  // Multiple files share this stem — ambiguous at the filename level
230
- const all = candidates.map((c) => c + rest);
231
- const valid = candidates.filter((c) => sectionIds.has((c + rest).toLowerCase()));
262
+ const all = filePaths.map((c) => c + rest);
263
+ const valid = filePaths.filter((c) => {
264
+ if (sectionIds.has((c + rest).toLowerCase()))
265
+ return true;
266
+ // Also try with root heading insertion
267
+ const rootHeadings = findRootHeadings(c, sectionIds);
268
+ return rootHeadings.some((h1) => {
269
+ const withRoot = rest ? `${c}#${h1}${rest}` : `${c}#${h1}`;
270
+ return sectionIds.has(withRoot.toLowerCase());
271
+ });
272
+ });
232
273
  return {
233
274
  resolved: target,
234
275
  ambiguous: all,
@@ -237,6 +278,20 @@ export function resolveRef(target, sectionIds, fileIndex) {
237
278
  }
238
279
  return { resolved: target, ambiguous: null, suggested: null };
239
280
  }
281
+ /**
282
+ * Find root (h1) headings for a file by scanning sectionIds for entries
283
+ * that have exactly the pattern `file#heading` (no further # segments).
284
+ */
285
+ function findRootHeadings(file, sectionIds) {
286
+ const prefix = file.toLowerCase() + '#';
287
+ const headings = [];
288
+ for (const id of sectionIds) {
289
+ if (id.startsWith(prefix) && !id.includes('#', prefix.length)) {
290
+ headings.push(id.slice(prefix.length));
291
+ }
292
+ }
293
+ return headings;
294
+ }
240
295
  const MAX_DISTANCE_RATIO = 0.4;
241
296
  export function findSections(sections, query) {
242
297
  const flat = flattenSections(sections);
@@ -252,36 +307,90 @@ export function findSections(sections, query) {
252
307
  }));
253
308
  if (exactMatches.length > 0 && isFullPath)
254
309
  return exactMatches;
310
+ // Build file index early — used by both tier 1a and 1b
311
+ const fileIndex = buildFileIndex(sections);
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")
314
+ if (!isFullPath && exactMatches.length === 0) {
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
+ }
336
+ }
337
+ }
255
338
  // Tier 1b: file stem expansion
256
339
  // For bare names: "locate" → matches root section of "tests/locate.md"
257
340
  // For paths with #: "setup#Install" → expands to "guides/setup#Install"
258
- const fileIndex = buildFileIndex(sections);
259
341
  const stemMatches = [];
260
342
  if (isFullPath) {
261
343
  // Expand file stem in the file part of the query
262
344
  const hashIdx = normalized.indexOf('#');
263
345
  const filePart = normalized.slice(0, hashIdx);
264
346
  const rest = normalized.slice(hashIdx);
265
- const paths = fileIndex.get(filePart) ?? [];
266
- for (const p of paths) {
347
+ const stemPaths = fileIndex.get(filePart.toLowerCase()) ?? [];
348
+ // Also try filePart as a direct file path (for root-level files not in index)
349
+ const allPaths = stemPaths.length > 0 ? stemPaths : filePart ? [filePart] : [];
350
+ for (const p of allPaths) {
267
351
  const expanded = (p + rest).toLowerCase();
268
352
  const s = flat.find((s) => s.id.toLowerCase() === expanded && !exact.includes(s));
269
- if (s)
353
+ if (s) {
270
354
  stemMatches.push({
271
355
  section: s,
272
- reason: `file stem expanded: ${filePart} → ${p}`,
356
+ reason: stemPaths.length > 0
357
+ ? `file stem expanded: ${filePart} → ${p}`
358
+ : 'exact match',
273
359
  });
360
+ continue;
361
+ }
362
+ // Try inserting root headings: file#rest → file#h1#rest
363
+ const rootsOfFile = flat.filter((s) => s.file.toLowerCase() === p.toLowerCase() &&
364
+ !s.id.includes('#', s.file.length + 1));
365
+ for (const root of rootsOfFile) {
366
+ const withRoot = (root.id + rest).toLowerCase();
367
+ const match = flat.find((s) => s.id.toLowerCase() === withRoot && !exact.includes(s));
368
+ if (match) {
369
+ stemMatches.push({
370
+ section: match,
371
+ reason: stemPaths.length > 0
372
+ ? `file stem expanded: ${filePart} → ${p}`
373
+ : 'exact match',
374
+ });
375
+ }
376
+ }
274
377
  }
275
378
  if (stemMatches.length > 0)
276
379
  return [...exactMatches, ...stemMatches];
277
380
  }
278
381
  else {
279
- // Bare name: match file root sections via stem index
280
- const paths = fileIndex.get(normalized) ?? [];
382
+ // Bare name: match root sections of files via stem index (keys lowercase)
383
+ const paths = fileIndex.get(q) ?? [];
281
384
  for (const p of paths) {
282
- const s = flat.find((s) => s.id.toLowerCase() === p.toLowerCase() && !exact.includes(s));
283
- if (s)
284
- stemMatches.push({ section: s, reason: 'file stem match' });
385
+ for (const s of flat) {
386
+ if (exact.includes(s))
387
+ continue;
388
+ // Root sections have id = "file#heading" (exactly 2 segments)
389
+ if (s.file.toLowerCase() === p.toLowerCase() &&
390
+ !s.id.includes('#', s.file.length + 1)) {
391
+ stemMatches.push({ section: s, reason: 'file stem match' });
392
+ }
393
+ }
285
394
  }
286
395
  }
287
396
  // Tier 2: exact match on trailing segments (subsection name match)
@@ -300,24 +409,37 @@ export function findSections(sections, query) {
300
409
  .map((s) => ({ section: s, reason: 'section name match' }));
301
410
  // Tier 2b: subsequence match — query segments are a subsequence of section id segments
302
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.
303
413
  const seenSub = new Set([...seen, ...subsection.map((m) => m.section.id)]);
304
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
+ }
305
425
  const subsequence = qParts.length >= 2
306
426
  ? flat
307
427
  .filter((s) => {
308
428
  if (seenSub.has(s.id))
309
429
  return false;
310
430
  const sParts = s.id.toLowerCase().split('#');
311
- 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
+ }
312
441
  return false;
313
- let qi = 0;
314
- for (const sp of sParts) {
315
- if (sp === qParts[qi])
316
- qi++;
317
- if (qi === qParts.length)
318
- return true;
319
- }
320
- return false;
442
+ });
321
443
  })
322
444
  .map((s) => {
323
445
  const skipped = s.id.split('#').length - qParts.length;
@@ -399,10 +521,10 @@ export function findSections(sections, query) {
399
521
  ...fuzzyMatches,
400
522
  ];
401
523
  }
402
- export function extractRefs(filePath, content, latticeDir) {
524
+ export function extractRefs(filePath, content, projectRoot) {
403
525
  const tree = parse(stripFrontmatter(content));
404
- const file = latticeDir
405
- ? relative(latticeDir, filePath).replace(/\.md$/, '')
526
+ const file = projectRoot
527
+ ? relative(projectRoot, filePath).replace(/\.md$/, '')
406
528
  : basename(filePath, '.md');
407
529
  const refs = [];
408
530
  // Build a flat list of sections to determine enclosing section for each wiki link
@@ -423,7 +545,7 @@ export function extractRefs(filePath, content, latticeDir) {
423
545
  stack.pop();
424
546
  }
425
547
  const parent = stack.length > 0 ? stack[stack.length - 1] : null;
426
- const id = parent ? `${parent.id}#${heading}` : file;
548
+ const id = parent ? `${parent.id}#${heading}` : `${file}#${heading}`;
427
549
  flat[idx].id = id;
428
550
  stack.push({ id, depth });
429
551
  idx++;
@@ -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.4.3",
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