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.
- package/dist/src/cli/check.js +58 -10
- package/dist/src/cli/context.d.ts +1 -0
- package/dist/src/cli/context.js +3 -1
- package/dist/src/cli/locate.js +1 -1
- package/dist/src/cli/prompt.js +6 -5
- package/dist/src/cli/refs.js +3 -5
- package/dist/src/cli/search.js +1 -1
- package/dist/src/format.d.ts +2 -2
- package/dist/src/format.js +5 -5
- package/dist/src/lattice.d.ts +10 -4
- package/dist/src/lattice.js +84 -35
- package/dist/src/mcp/server.js +14 -13
- package/dist/src/search/index.js +5 -4
- package/dist/src/source-parser.d.ts +23 -0
- package/dist/src/source-parser.js +333 -0
- package/package.json +3 -1
- package/templates/AGENTS.md +3 -2
- package/templates/cursor-rules.md +3 -2
package/dist/src/cli/check.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import {
|
|
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,
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 =
|
|
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,
|
|
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);
|
package/dist/src/cli/context.js
CHANGED
|
@@ -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
|
-
|
|
15
|
+
const projectRoot = dirname(latDir);
|
|
16
|
+
return { latDir, projectRoot, color, chalk };
|
|
15
17
|
}
|
package/dist/src/cli/locate.js
CHANGED
|
@@ -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.
|
|
11
|
+
console.log(formatResultList(`Sections matching "${stripped}":`, matches, ctx.projectRoot));
|
|
12
12
|
}
|
package/dist/src/cli/prompt.js
CHANGED
|
@@ -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,
|
|
5
|
-
const relPath = relative(process.cwd(),
|
|
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.
|
|
53
|
+
output += ` * ${formatLocation(m.section, ctx.projectRoot)}\n`;
|
|
53
54
|
if (m.section.body) {
|
|
54
55
|
output += ` * ${m.section.body}\n`;
|
|
55
56
|
}
|
package/dist/src/cli/refs.js
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
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)
|
package/dist/src/cli/search.js
CHANGED
|
@@ -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.
|
|
62
|
+
console.log(formatResultList(`Search results for "${query}":`, matched, ctx.projectRoot));
|
|
63
63
|
}
|
|
64
64
|
finally {
|
|
65
65
|
await closeDb(db);
|
package/dist/src/format.d.ts
CHANGED
|
@@ -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,
|
|
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[],
|
|
6
|
+
export declare function formatResultList(header: string, matches: SectionMatch[], projectRoot: string): string;
|
package/dist/src/format.js
CHANGED
|
@@ -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,
|
|
11
|
-
const relPath = relative(process.cwd(),
|
|
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,
|
|
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,
|
|
32
|
+
lines.push(formatSectionPreview(matches[i].section, projectRoot, {
|
|
33
33
|
reason: matches[i].reason,
|
|
34
34
|
}));
|
|
35
35
|
}
|
package/dist/src/lattice.d.ts
CHANGED
|
@@ -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,
|
|
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
|
|
29
|
-
* Used by resolveRef to allow short references when a
|
|
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,
|
|
59
|
+
export declare function extractRefs(filePath: string, content: string, projectRoot?: string): Ref[];
|
package/dist/src/lattice.js
CHANGED
|
@@ -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,
|
|
71
|
+
export function parseSections(filePath, content, projectRoot) {
|
|
68
72
|
const tree = parse(stripFrontmatter(content));
|
|
69
|
-
const file =
|
|
70
|
-
? relative(
|
|
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,
|
|
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
|
|
184
|
-
* Used by resolveRef to allow short references when a
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
524
|
+
export function extractRefs(filePath, content, projectRoot) {
|
|
476
525
|
const tree = parse(stripFrontmatter(content));
|
|
477
|
-
const file =
|
|
478
|
-
? relative(
|
|
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
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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(),
|
|
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,
|
|
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,
|
|
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'));
|
package/dist/src/search/index.js
CHANGED
|
@@ -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,
|
|
10
|
-
const filePath = join(
|
|
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,
|
|
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.
|
|
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
|
}
|
package/templates/AGENTS.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|