lat.md 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/src/cli/check.d.ts +7 -5
  2. package/dist/src/cli/check.js +243 -74
  3. package/dist/src/cli/context.d.ts +3 -7
  4. package/dist/src/cli/context.js +15 -1
  5. package/dist/src/cli/expand.d.ts +7 -0
  6. package/dist/src/cli/expand.js +92 -0
  7. package/dist/src/cli/gen.js +11 -4
  8. package/dist/src/cli/hook.d.ts +1 -0
  9. package/dist/src/cli/hook.js +147 -0
  10. package/dist/src/cli/index.js +77 -28
  11. package/dist/src/cli/init.js +148 -120
  12. package/dist/src/cli/locate.d.ts +2 -2
  13. package/dist/src/cli/locate.js +9 -4
  14. package/dist/src/cli/refs.d.ts +20 -4
  15. package/dist/src/cli/refs.js +64 -42
  16. package/dist/src/cli/search.d.ts +25 -3
  17. package/dist/src/cli/search.js +87 -47
  18. package/dist/src/cli/section.d.ts +26 -0
  19. package/dist/src/cli/section.js +133 -0
  20. package/dist/src/code-refs.js +2 -1
  21. package/dist/src/config.js +3 -2
  22. package/dist/src/context.d.ts +21 -0
  23. package/dist/src/context.js +11 -0
  24. package/dist/src/format.d.ts +4 -3
  25. package/dist/src/format.js +16 -20
  26. package/dist/src/init-version.d.ts +10 -0
  27. package/dist/src/init-version.js +49 -0
  28. package/dist/src/lattice.d.ts +11 -5
  29. package/dist/src/lattice.js +87 -38
  30. package/dist/src/mcp/server.js +27 -279
  31. package/dist/src/search/index.js +5 -4
  32. package/dist/src/source-parser.d.ts +23 -0
  33. package/dist/src/source-parser.js +720 -0
  34. package/package.json +3 -1
  35. package/templates/AGENTS.md +38 -6
  36. package/templates/cursor-rules.md +11 -5
  37. package/templates/lat-prompt-hook.sh +2 -2
  38. package/dist/src/cli/prompt.d.ts +0 -2
  39. package/dist/src/cli/prompt.js +0 -60
@@ -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,10 +94,11 @@ 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,
93
- body: '',
101
+ firstParagraph: '',
94
102
  };
95
103
  if (parent) {
96
104
  parent.children.push(section);
@@ -111,7 +119,7 @@ export function parseSections(filePath, content, latticeDir) {
111
119
  flat[i].endLine = fileLastLine;
112
120
  }
113
121
  }
114
- // Extract body: first paragraph after each heading
122
+ // Extract firstParagraph: first paragraph after each heading
115
123
  const children = tree.children;
116
124
  let headingIdx = 0;
117
125
  for (let i = 0; i < children.length; i++) {
@@ -122,7 +130,7 @@ export function parseSections(filePath, content, latticeDir) {
122
130
  if (children[j].type === 'heading')
123
131
  break;
124
132
  if (children[j].type === 'paragraph') {
125
- flat[headingIdx].body = inlineText(children[j]);
133
+ flat[headingIdx].firstParagraph = inlineText(children[j]);
126
134
  break;
127
135
  }
128
136
  }
@@ -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,33 +1,18 @@
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';
5
- import { findLatticeDir, loadAllSections, findSections, flattenSections, buildFileIndex, resolveRef, extractRefs, listLatticeFiles, } from '../lattice.js';
6
- import { scanCodeRefs } from '../code-refs.js';
7
- import { checkMd, checkCodeRefs, checkIndex } from '../cli/check.js';
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');
12
- const kind = s.id.includes('#') ? 'Section' : 'File';
13
- const lines = [
14
- `* ${kind}: [[${s.id}]]`,
15
- ` Defined in ${relPath}:${s.startLine}-${s.endLine}`,
16
- ];
17
- if (s.body) {
18
- const truncated = s.body.length > 200 ? s.body.slice(0, 200) + '...' : s.body;
19
- lines.push('', ` > ${truncated}`);
20
- }
21
- return lines.join('\n');
22
- }
23
- function formatMatches(header, matches, latDir) {
24
- const lines = [header, ''];
25
- for (let i = 0; i < matches.length; i++) {
26
- if (i > 0)
27
- lines.push('');
28
- lines.push(formatSection(matches[i].section, latDir) + ` (${matches[i].reason})`);
29
- }
30
- return lines.join('\n');
4
+ import { dirname } from 'node:path';
5
+ import { findLatticeDir } from '../lattice.js';
6
+ import { plainStyler } from '../context.js';
7
+ import { locateCommand } from '../cli/locate.js';
8
+ import { sectionCommand } from '../cli/section.js';
9
+ import { searchCommand } from '../cli/search.js';
10
+ import { expandCommand } from '../cli/expand.js';
11
+ import { checkAllCommand } from '../cli/check.js';
12
+ import { refsCommand } from '../cli/refs.js';
13
+ function toMcp(result) {
14
+ const content = [{ type: 'text', text: result.output }];
15
+ return result.isError ? { content, isError: true } : { content };
31
16
  }
32
17
  export async function startMcpServer() {
33
18
  const latDir = findLatticeDir();
@@ -35,32 +20,21 @@ export async function startMcpServer() {
35
20
  process.stderr.write('No lat.md directory found\n');
36
21
  process.exit(1);
37
22
  }
23
+ const projectRoot = dirname(latDir);
24
+ const ctx = {
25
+ latDir,
26
+ projectRoot,
27
+ styler: plainStyler,
28
+ mode: 'mcp',
29
+ };
38
30
  const server = new McpServer({
39
31
  name: 'lat',
40
32
  version: '1.0.0',
41
33
  });
42
- server.tool('lat_locate', 'Find sections by name (exact, fuzzy, subsequence matching)', { query: z.string().describe('Section name or id to search for') }, async ({ query }) => {
43
- const sections = await loadAllSections(latDir);
44
- const matches = findSections(sections, query.replace(/^\[\[|\]\]$/g, ''));
45
- if (matches.length === 0) {
46
- return {
47
- content: [
48
- {
49
- type: 'text',
50
- text: `No sections matching "${query}"`,
51
- },
52
- ],
53
- };
54
- }
55
- return {
56
- content: [
57
- {
58
- type: 'text',
59
- text: formatMatches(`Sections matching "${query}":`, matches, latDir),
60
- },
61
- ],
62
- };
63
- });
34
+ server.tool('lat_locate', 'Find sections by name (exact, fuzzy, subsequence matching)', { query: z.string().describe('Section name or id to search for') }, async ({ query }) => toMcp(await locateCommand(ctx, query)));
35
+ server.tool('lat_section', 'Show a section with its content, outgoing wiki link targets, and incoming references', {
36
+ query: z.string().describe('Section id to look up (short or full form)'),
37
+ }, async ({ query }) => toMcp(await sectionCommand(ctx, query)));
64
38
  server.tool('lat_search', 'Semantic search across lat.md sections using embeddings', {
65
39
  query: z.string().describe('Search query in natural language'),
66
40
  limit: z
@@ -68,150 +42,9 @@ export async function startMcpServer() {
68
42
  .optional()
69
43
  .default(5)
70
44
  .describe('Max results (default 5)'),
71
- }, async ({ query, limit }) => {
72
- const { getLlmKey } = await import('../config.js');
73
- let key;
74
- try {
75
- key = getLlmKey();
76
- }
77
- catch (err) {
78
- return {
79
- content: [{ type: 'text', text: err.message }],
80
- isError: true,
81
- };
82
- }
83
- if (!key) {
84
- return {
85
- content: [
86
- {
87
- type: 'text',
88
- text: 'No LLM key found. Provide a key via LAT_LLM_KEY, LAT_LLM_KEY_FILE, LAT_LLM_KEY_HELPER, or run `lat init`.',
89
- },
90
- ],
91
- isError: true,
92
- };
93
- }
94
- const { detectProvider } = await import('../search/provider.js');
95
- const { openDb, ensureSchema, closeDb } = await import('../search/db.js');
96
- const { indexSections } = await import('../search/index.js');
97
- const { searchSections } = await import('../search/search.js');
98
- const provider = detectProvider(key);
99
- const db = openDb(latDir);
100
- try {
101
- await ensureSchema(db, provider.dimensions);
102
- await indexSections(latDir, db, provider, key);
103
- const results = await searchSections(db, query, provider, key, limit);
104
- if (results.length === 0) {
105
- return {
106
- content: [{ type: 'text', text: 'No results found.' }],
107
- };
108
- }
109
- const allSections = await loadAllSections(latDir);
110
- const flat = flattenSections(allSections);
111
- const byId = new Map(flat.map((s) => [s.id, s]));
112
- const matched = results
113
- .map((r) => byId.get(r.id))
114
- .filter((s) => !!s)
115
- .map((s) => ({ section: s, reason: 'semantic match' }));
116
- return {
117
- content: [
118
- {
119
- type: 'text',
120
- text: formatMatches(`Search results for "${query}":`, matched, latDir),
121
- },
122
- ],
123
- };
124
- }
125
- finally {
126
- await closeDb(db);
127
- }
128
- });
129
- server.tool('lat_prompt', 'Expand [[refs]] in text to resolved lat.md section paths with context', { text: z.string().describe('Text containing [[refs]] to expand') }, async ({ text }) => {
130
- const WIKI_LINK_RE = /\[\[([^\]]+)\]\]/g;
131
- const allSections = await loadAllSections(latDir);
132
- const refs = [...text.matchAll(WIKI_LINK_RE)];
133
- if (refs.length === 0) {
134
- return {
135
- content: [{ type: 'text', text }],
136
- };
137
- }
138
- const resolved = new Map();
139
- const errors = [];
140
- for (const match of refs) {
141
- const target = match[1];
142
- if (resolved.has(target))
143
- continue;
144
- const matches = findSections(allSections, target);
145
- if (matches.length >= 1) {
146
- resolved.set(target, {
147
- target,
148
- best: matches[0],
149
- alternatives: matches.slice(1),
150
- });
151
- }
152
- else {
153
- errors.push(`No section found for [[${target}]]`);
154
- }
155
- }
156
- if (errors.length > 0) {
157
- return {
158
- content: [{ type: 'text', text: errors.join('\n') }],
159
- isError: true,
160
- };
161
- }
162
- let output = text.replace(WIKI_LINK_RE, (_match, target) => {
163
- const ref = resolved.get(target);
164
- return `[[${ref.best.section.id}]]`;
165
- });
166
- output += '\n\n<lat-context>\n';
167
- for (const ref of resolved.values()) {
168
- const isExact = ref.best.reason === 'exact match';
169
- const all = isExact ? [ref.best] : [ref.best, ...ref.alternatives];
170
- if (isExact) {
171
- output += `* [[${ref.target}]] is referring to:\n`;
172
- }
173
- else {
174
- output += `* [[${ref.target}]] might be referring to either of the following:\n`;
175
- }
176
- for (const m of all) {
177
- const reason = isExact ? '' : ` (${m.reason})`;
178
- const relPath = relative(process.cwd(), latDir + '/' + m.section.file + '.md');
179
- output += ` * [[${m.section.id}]]${reason}\n`;
180
- output += ` * ${relPath}:${m.section.startLine}-${m.section.endLine}\n`;
181
- if (m.section.body) {
182
- output += ` * ${m.section.body}\n`;
183
- }
184
- }
185
- }
186
- output += '</lat-context>\n';
187
- return {
188
- content: [{ type: 'text', text: output }],
189
- };
190
- });
191
- server.tool('lat_check', 'Validate all wiki links, code references, and directory indexes in lat.md', {}, async () => {
192
- const md = await checkMd(latDir);
193
- const code = await checkCodeRefs(latDir);
194
- const indexErrors = await checkIndex(latDir);
195
- const allErrors = [...md.errors, ...code.errors];
196
- const lines = [];
197
- for (const err of allErrors) {
198
- lines.push(`${err.file}:${err.line}: ${err.message}`);
199
- }
200
- for (const err of indexErrors) {
201
- lines.push(`${err.dir}: ${err.message}`);
202
- }
203
- const totalErrors = allErrors.length + indexErrors.length;
204
- if (totalErrors === 0) {
205
- return {
206
- content: [{ type: 'text', text: 'All checks passed' }],
207
- };
208
- }
209
- lines.push(`\n${totalErrors} error${totalErrors === 1 ? '' : 's'} found`);
210
- return {
211
- content: [{ type: 'text', text: lines.join('\n') }],
212
- isError: true,
213
- };
214
- });
45
+ }, async ({ query, limit }) => toMcp(await searchCommand(ctx, query, { limit })));
46
+ server.tool('lat_expand', 'Expand [[refs]] in text to resolved lat.md section paths with context', { text: z.string().describe('Text containing [[refs]] to expand') }, async ({ text: input }) => toMcp(await expandCommand(ctx, input)));
47
+ server.tool('lat_check', 'Validate all wiki links, code references, and directory indexes in lat.md', {}, async () => toMcp(await checkAllCommand(ctx)));
215
48
  server.tool('lat_refs', 'Find sections that reference a given section via wiki links or @lat code comments', {
216
49
  query: z.string().describe('Section id to find references for'),
217
50
  scope: z
@@ -219,92 +52,7 @@ export async function startMcpServer() {
219
52
  .optional()
220
53
  .default('md')
221
54
  .describe('Where to search: md, code, or md+code'),
222
- }, async ({ query, scope }) => {
223
- const allSections = await loadAllSections(latDir);
224
- const flat = flattenSections(allSections);
225
- const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
226
- const fileIndex = buildFileIndex(allSections);
227
- const { resolved } = resolveRef(query, sectionIds, fileIndex);
228
- const q = resolved.toLowerCase();
229
- const exactMatch = flat.find((s) => s.id.toLowerCase() === q);
230
- if (!exactMatch) {
231
- const matches = findSections(allSections, query);
232
- if (matches.length > 0) {
233
- const suggestions = matches
234
- .map((m) => ` * ${m.section.id} (${m.reason})`)
235
- .join('\n');
236
- return {
237
- content: [
238
- {
239
- type: 'text',
240
- text: `No exact section "${query}" found. Did you mean:\n${suggestions}`,
241
- },
242
- ],
243
- };
244
- }
245
- return {
246
- content: [
247
- {
248
- type: 'text',
249
- text: `No section matching "${query}"`,
250
- },
251
- ],
252
- };
253
- }
254
- const targetId = exactMatch.id.toLowerCase();
255
- const mdMatches = [];
256
- const codeLines = [];
257
- if (scope === 'md' || scope === 'md+code') {
258
- const files = await listLatticeFiles(latDir);
259
- const matchingFromSections = new Set();
260
- for (const file of files) {
261
- const content = await readFile(file, 'utf-8');
262
- const fileRefs = extractRefs(file, content, latDir);
263
- for (const ref of fileRefs) {
264
- const { resolved: refResolved } = resolveRef(ref.target, sectionIds, fileIndex);
265
- if (refResolved.toLowerCase() === targetId) {
266
- matchingFromSections.add(ref.fromSection.toLowerCase());
267
- }
268
- }
269
- }
270
- if (matchingFromSections.size > 0) {
271
- const referrers = flat.filter((s) => matchingFromSections.has(s.id.toLowerCase()));
272
- for (const s of referrers) {
273
- mdMatches.push({ section: s, reason: 'wiki link' });
274
- }
275
- }
276
- }
277
- if (scope === 'code' || scope === 'md+code') {
278
- const projectRoot = join(latDir, '..');
279
- const { refs: codeRefs } = await scanCodeRefs(projectRoot);
280
- for (const ref of codeRefs) {
281
- const { resolved: codeResolved } = resolveRef(ref.target, sectionIds, fileIndex);
282
- if (codeResolved.toLowerCase() === targetId) {
283
- codeLines.push(`${ref.file}:${ref.line}`);
284
- }
285
- }
286
- }
287
- if (mdMatches.length === 0 && codeLines.length === 0) {
288
- return {
289
- content: [
290
- {
291
- type: 'text',
292
- text: `No references to "${exactMatch.id}" found`,
293
- },
294
- ],
295
- };
296
- }
297
- const parts = [];
298
- if (mdMatches.length > 0) {
299
- parts.push(formatMatches(`References to "${exactMatch.id}":`, mdMatches, latDir));
300
- }
301
- if (codeLines.length > 0) {
302
- parts.push('Code references:\n' + codeLines.map((l) => `* ${l}`).join('\n'));
303
- }
304
- return {
305
- content: [{ type: 'text', text: parts.join('\n\n') }],
306
- };
307
- });
55
+ }, async ({ query, scope }) => toMcp(await refsCommand(ctx, query, scope)));
308
56
  const transport = new StdioServerTransport();
309
57
  await server.connect(transport);
310
58
  }
@@ -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[];