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.
- package/dist/src/cli/check.js +77 -17
- package/dist/src/cli/context.d.ts +1 -0
- package/dist/src/cli/context.js +4 -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 +163 -41
- 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);
|
|
@@ -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 `-
|
|
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 = /^-
|
|
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) => `-
|
|
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 (!
|
|
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
|
}
|
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) {
|
|
@@ -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
|
-
|
|
15
|
+
const projectRoot = dirname(latDir);
|
|
16
|
+
return { latDir, projectRoot, color, chalk };
|
|
14
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 = [];
|
|
@@ -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,
|
|
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();
|
|
@@ -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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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 (
|
|
260
|
+
else if (filePaths.length > 1) {
|
|
229
261
|
// Multiple files share this stem — ambiguous at the filename level
|
|
230
|
-
const all =
|
|
231
|
-
const valid =
|
|
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
|
|
266
|
-
|
|
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:
|
|
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
|
|
280
|
-
const paths = fileIndex.get(
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
524
|
+
export function extractRefs(filePath, content, projectRoot) {
|
|
403
525
|
const tree = parse(stripFrontmatter(content));
|
|
404
|
-
const file =
|
|
405
|
-
? relative(
|
|
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++;
|
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
|