lat.md 0.1.3 → 0.2.3
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/README.md +4 -1
- package/dist/src/cli/check.d.ts +7 -0
- package/dist/src/cli/check.js +184 -15
- package/dist/src/cli/index.js +8 -0
- package/dist/src/cli/prompt.js +6 -2
- package/dist/src/cli/refs.js +11 -6
- package/dist/src/code-refs.d.ts +2 -1
- package/dist/src/code-refs.js +4 -8
- package/dist/src/lattice.d.ts +23 -2
- package/dist/src/lattice.js +96 -15
- package/dist/src/walk.d.ts +13 -0
- package/dist/src/walk.js +32 -0
- package/package.json +1 -1
- package/templates/AGENTS.md +6 -1
- package/templates/init/lat.md +1 -0
- package/dist/src/cli.d.ts +0 -2
- package/dist/src/cli.js +0 -23
- package/templates/init/README.md +0 -5
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# lat.md
|
|
2
2
|
|
|
3
3
|
[](https://github.com/1st1/lat.md/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/lat.md)
|
|
4
5
|
|
|
5
6
|
A knowledge graph for your codebase, written in markdown.
|
|
6
7
|
|
|
@@ -26,7 +27,9 @@ The result is a structured knowledge base that:
|
|
|
26
27
|
npm install -g lat.md
|
|
27
28
|
```
|
|
28
29
|
|
|
29
|
-
Or use directly with `npx lat.md <command>`.
|
|
30
|
+
Or use directly with `npx lat.md@latest <command>`.
|
|
31
|
+
|
|
32
|
+
For semantic search (`lat search`), set the `LAT_LLM_KEY` environment variable with an OpenAI (`sk-...`) or Vercel AI Gateway (`vck_...`) API key.
|
|
30
33
|
|
|
31
34
|
## How it works
|
|
32
35
|
|
package/dist/src/cli/check.d.ts
CHANGED
|
@@ -13,6 +13,13 @@ export type CheckResult = {
|
|
|
13
13
|
};
|
|
14
14
|
export declare function checkMd(latticeDir: string): Promise<CheckResult>;
|
|
15
15
|
export declare function checkCodeRefs(latticeDir: string): Promise<CheckResult>;
|
|
16
|
+
export type IndexError = {
|
|
17
|
+
dir: string;
|
|
18
|
+
message: string;
|
|
19
|
+
snippet?: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function checkIndex(latticeDir: string): Promise<IndexError[]>;
|
|
16
22
|
export declare function checkMdCmd(ctx: CliContext): Promise<void>;
|
|
17
23
|
export declare function checkCodeRefsCmd(ctx: CliContext): Promise<void>;
|
|
24
|
+
export declare function checkIndexCmd(ctx: CliContext): Promise<void>;
|
|
18
25
|
export declare function checkAllCmd(ctx: CliContext): Promise<void>;
|
package/dist/src/cli/check.js
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { extname, join, relative } from 'node:path';
|
|
3
|
-
import { listLatticeFiles, loadAllSections, extractRefs, flattenSections, parseFrontmatter, parseSections, } from '../lattice.js';
|
|
2
|
+
import { basename, extname, join, relative } from 'node:path';
|
|
3
|
+
import { listLatticeFiles, loadAllSections, extractRefs, flattenSections, parseFrontmatter, parseSections, buildFileIndex, resolveRef, } from '../lattice.js';
|
|
4
4
|
import { scanCodeRefs } from '../code-refs.js';
|
|
5
|
+
import { walkEntries } from '../walk.js';
|
|
6
|
+
function filePart(id) {
|
|
7
|
+
const h = id.indexOf('#');
|
|
8
|
+
return h === -1 ? id : id.slice(0, h);
|
|
9
|
+
}
|
|
10
|
+
/** Format an ambiguous-ref error as structured markdown-like text. */
|
|
11
|
+
function ambiguousMessage(target, candidates, suggested) {
|
|
12
|
+
const shortName = filePart(target);
|
|
13
|
+
const fileList = candidates.map((c) => ` - "${filePart(c)}.md"`).join('\n');
|
|
14
|
+
const lines = [];
|
|
15
|
+
if (suggested) {
|
|
16
|
+
lines.push(`ambiguous link '[[${target}]]' — did you mean '[[${suggested}]]'?`);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
const options = candidates.map((a) => `'[[${a}]]'`).join(', ');
|
|
20
|
+
lines.push(`ambiguous link '[[${target}]]' — multiple paths match, use either of: ${options}`);
|
|
21
|
+
}
|
|
22
|
+
lines.push(` The short path "${shortName}" is ambiguous — ${candidates.length} files match:`, fileList, ` Please fix the link to use a fully qualified path.`);
|
|
23
|
+
return lines.join('\n');
|
|
24
|
+
}
|
|
5
25
|
function countByExt(paths) {
|
|
6
26
|
const stats = {};
|
|
7
27
|
for (const p of paths) {
|
|
@@ -15,14 +35,23 @@ export async function checkMd(latticeDir) {
|
|
|
15
35
|
const allSections = await loadAllSections(latticeDir);
|
|
16
36
|
const flat = flattenSections(allSections);
|
|
17
37
|
const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
|
|
38
|
+
const fileIndex = buildFileIndex(allSections);
|
|
18
39
|
const errors = [];
|
|
19
40
|
for (const file of files) {
|
|
20
41
|
const content = await readFile(file, 'utf-8');
|
|
21
|
-
const refs = extractRefs(file, content);
|
|
42
|
+
const refs = extractRefs(file, content, latticeDir);
|
|
22
43
|
const relPath = relative(process.cwd(), file);
|
|
23
44
|
for (const ref of refs) {
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
45
|
+
const { resolved, ambiguous, suggested } = resolveRef(ref.target, sectionIds, fileIndex);
|
|
46
|
+
if (ambiguous) {
|
|
47
|
+
errors.push({
|
|
48
|
+
file: relPath,
|
|
49
|
+
line: ref.line,
|
|
50
|
+
target: ref.target,
|
|
51
|
+
message: ambiguousMessage(ref.target, ambiguous, suggested),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
else if (!sectionIds.has(resolved.toLowerCase())) {
|
|
26
55
|
errors.push({
|
|
27
56
|
file: relPath,
|
|
28
57
|
line: ref.line,
|
|
@@ -39,13 +68,22 @@ export async function checkCodeRefs(latticeDir) {
|
|
|
39
68
|
const allSections = await loadAllSections(latticeDir);
|
|
40
69
|
const flat = flattenSections(allSections);
|
|
41
70
|
const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
|
|
71
|
+
const fileIndex = buildFileIndex(allSections);
|
|
42
72
|
const scan = await scanCodeRefs(projectRoot);
|
|
43
73
|
const errors = [];
|
|
44
74
|
const mentionedSections = new Set();
|
|
45
75
|
for (const ref of scan.refs) {
|
|
46
|
-
const
|
|
47
|
-
mentionedSections.add(
|
|
48
|
-
if (
|
|
76
|
+
const { resolved, ambiguous, suggested } = resolveRef(ref.target, sectionIds, fileIndex);
|
|
77
|
+
mentionedSections.add(resolved.toLowerCase());
|
|
78
|
+
if (ambiguous) {
|
|
79
|
+
errors.push({
|
|
80
|
+
file: ref.file,
|
|
81
|
+
line: ref.line,
|
|
82
|
+
target: ref.target,
|
|
83
|
+
message: ambiguousMessage(ref.target, ambiguous, suggested),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
else if (!sectionIds.has(resolved.toLowerCase())) {
|
|
49
87
|
errors.push({
|
|
50
88
|
file: ref.file,
|
|
51
89
|
line: ref.line,
|
|
@@ -60,7 +98,7 @@ export async function checkCodeRefs(latticeDir) {
|
|
|
60
98
|
const fm = parseFrontmatter(content);
|
|
61
99
|
if (!fm.requireCodeMention)
|
|
62
100
|
continue;
|
|
63
|
-
const sections = parseSections(file, content);
|
|
101
|
+
const sections = parseSections(file, content, latticeDir);
|
|
64
102
|
const fileSections = flattenSections(sections);
|
|
65
103
|
const leafSections = fileSections.filter((s) => s.children.length === 0);
|
|
66
104
|
const relPath = relative(process.cwd(), file);
|
|
@@ -77,12 +115,129 @@ export async function checkCodeRefs(latticeDir) {
|
|
|
77
115
|
}
|
|
78
116
|
return { errors, files: countByExt(scan.files) };
|
|
79
117
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Extract the immediate (first-level) entries from walkEntries results.
|
|
120
|
+
* Returns unique file and directory names visible in a given directory.
|
|
121
|
+
*/
|
|
122
|
+
function immediateEntries(walkedPaths) {
|
|
123
|
+
const entries = new Set();
|
|
124
|
+
for (const p of walkedPaths) {
|
|
125
|
+
const slash = p.indexOf('/');
|
|
126
|
+
entries.add(slash === -1 ? p : p.slice(0, slash));
|
|
83
127
|
}
|
|
84
|
-
|
|
85
|
-
|
|
128
|
+
return [...entries].sort();
|
|
129
|
+
}
|
|
130
|
+
/** Parse bullet items from an index file. Matches `- **name** — description` */
|
|
131
|
+
function parseIndexEntries(content) {
|
|
132
|
+
const names = new Set();
|
|
133
|
+
const re = /^- \*\*(.+?)\*\*/gm;
|
|
134
|
+
let match;
|
|
135
|
+
while ((match = re.exec(content)) !== null) {
|
|
136
|
+
names.add(match[1]);
|
|
137
|
+
}
|
|
138
|
+
return names;
|
|
139
|
+
}
|
|
140
|
+
/** Generate a bullet-list snippet for the given entry names. */
|
|
141
|
+
function indexSnippet(entries) {
|
|
142
|
+
return entries.map((e) => `- **${e}** — <describe>`).join('\n');
|
|
143
|
+
}
|
|
144
|
+
export async function checkIndex(latticeDir) {
|
|
145
|
+
const errors = [];
|
|
146
|
+
const allPaths = await walkEntries(latticeDir);
|
|
147
|
+
// Collect all directories to check (including root, represented as '')
|
|
148
|
+
const dirs = new Set(['']);
|
|
149
|
+
for (const p of allPaths) {
|
|
150
|
+
const parts = p.split('/');
|
|
151
|
+
// Add every directory prefix
|
|
152
|
+
for (let i = 1; i < parts.length; i++) {
|
|
153
|
+
dirs.add(parts.slice(0, i).join('/'));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const dir of dirs) {
|
|
157
|
+
// Determine the index file name and its expected path.
|
|
158
|
+
// The index file shares the directory's name — for `lat.md/` it's `lat.md`,
|
|
159
|
+
// for a subdir `api/` it's `api.md`.
|
|
160
|
+
const dirName = dir === '' ? basename(latticeDir) : dir.split('/').pop();
|
|
161
|
+
const indexFileName = dirName.endsWith('.md') ? dirName : dirName + '.md';
|
|
162
|
+
const indexRelPath = dir === '' ? indexFileName : dir + '/' + indexFileName;
|
|
163
|
+
// Get the immediate children of this directory
|
|
164
|
+
const prefix = dir === '' ? '' : dir + '/';
|
|
165
|
+
const childPaths = allPaths
|
|
166
|
+
.filter((p) => p.startsWith(prefix) && p !== indexRelPath)
|
|
167
|
+
.map((p) => p.slice(prefix.length));
|
|
168
|
+
const children = immediateEntries(childPaths);
|
|
169
|
+
if (children.length === 0)
|
|
170
|
+
continue;
|
|
171
|
+
// Check if the index file exists
|
|
172
|
+
const indexFullPath = join(latticeDir, indexRelPath);
|
|
173
|
+
let content;
|
|
174
|
+
try {
|
|
175
|
+
content = await readFile(indexFullPath, 'utf-8');
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
const relDir = dir === '' ? basename(latticeDir) + '/' : dir + '/';
|
|
179
|
+
errors.push({
|
|
180
|
+
dir: relDir,
|
|
181
|
+
message: `missing index file "${indexRelPath}" — create it with a directory listing:\n\n${indexSnippet(children)}`,
|
|
182
|
+
snippet: indexSnippet(children),
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// Parse existing entries and validate
|
|
187
|
+
const listed = parseIndexEntries(content);
|
|
188
|
+
const relDir = dir === '' ? basename(latticeDir) + '/' : dir + '/';
|
|
189
|
+
const missing = [];
|
|
190
|
+
for (const child of children) {
|
|
191
|
+
if (!listed.has(child)) {
|
|
192
|
+
missing.push(child);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (missing.length > 0) {
|
|
196
|
+
errors.push({
|
|
197
|
+
dir: relDir,
|
|
198
|
+
message: `"${indexRelPath}" is missing entries — add:\n\n${indexSnippet(missing)}`,
|
|
199
|
+
snippet: indexSnippet(missing),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
for (const name of listed) {
|
|
203
|
+
if (!children.includes(name) && name !== indexFileName) {
|
|
204
|
+
errors.push({
|
|
205
|
+
dir: relDir,
|
|
206
|
+
message: `"${indexRelPath}" lists "${name}" but it does not exist`,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return errors;
|
|
212
|
+
}
|
|
213
|
+
function formatErrors(ctx, errors, startIdx = 0) {
|
|
214
|
+
for (let i = 0; i < errors.length; i++) {
|
|
215
|
+
const err = errors[i];
|
|
216
|
+
if (i > 0 || startIdx > 0)
|
|
217
|
+
console.error('');
|
|
218
|
+
const loc = ctx.chalk.cyan(err.file + ':' + err.line);
|
|
219
|
+
const [first, ...rest] = err.message.split('\n');
|
|
220
|
+
console.error(`- ${loc}: ${ctx.chalk.red(first)}`);
|
|
221
|
+
for (const line of rest) {
|
|
222
|
+
console.error(` ${ctx.chalk.red(line)}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function formatIndexErrors(ctx, errors, startIdx = 0) {
|
|
227
|
+
for (let i = 0; i < errors.length; i++) {
|
|
228
|
+
if (i > 0 || startIdx > 0)
|
|
229
|
+
console.error('');
|
|
230
|
+
const loc = ctx.chalk.cyan(errors[i].dir);
|
|
231
|
+
const [first, ...rest] = errors[i].message.split('\n');
|
|
232
|
+
console.error(`- ${loc}: ${ctx.chalk.red(first)}`);
|
|
233
|
+
for (const line of rest) {
|
|
234
|
+
console.error(` ${ctx.chalk.red(line)}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function formatErrorCount(ctx, count) {
|
|
239
|
+
if (count > 0) {
|
|
240
|
+
console.error(ctx.chalk.red(`\n${count} error${count === 1 ? '' : 's'} found`));
|
|
86
241
|
}
|
|
87
242
|
}
|
|
88
243
|
function formatStats(ctx, stats) {
|
|
@@ -94,6 +249,7 @@ export async function checkMdCmd(ctx) {
|
|
|
94
249
|
const { errors, files } = await checkMd(ctx.latDir);
|
|
95
250
|
formatStats(ctx, files);
|
|
96
251
|
formatErrors(ctx, errors);
|
|
252
|
+
formatErrorCount(ctx, errors.length);
|
|
97
253
|
if (errors.length > 0)
|
|
98
254
|
process.exit(1);
|
|
99
255
|
console.log(ctx.chalk.green('md: All links OK'));
|
|
@@ -102,13 +258,23 @@ export async function checkCodeRefsCmd(ctx) {
|
|
|
102
258
|
const { errors, files } = await checkCodeRefs(ctx.latDir);
|
|
103
259
|
formatStats(ctx, files);
|
|
104
260
|
formatErrors(ctx, errors);
|
|
261
|
+
formatErrorCount(ctx, errors.length);
|
|
105
262
|
if (errors.length > 0)
|
|
106
263
|
process.exit(1);
|
|
107
264
|
console.log(ctx.chalk.green('code-refs: All references OK'));
|
|
108
265
|
}
|
|
266
|
+
export async function checkIndexCmd(ctx) {
|
|
267
|
+
const errors = await checkIndex(ctx.latDir);
|
|
268
|
+
formatIndexErrors(ctx, errors);
|
|
269
|
+
formatErrorCount(ctx, errors.length);
|
|
270
|
+
if (errors.length > 0)
|
|
271
|
+
process.exit(1);
|
|
272
|
+
console.log(ctx.chalk.green('index: All directory index files OK'));
|
|
273
|
+
}
|
|
109
274
|
export async function checkAllCmd(ctx) {
|
|
110
275
|
const md = await checkMd(ctx.latDir);
|
|
111
276
|
const code = await checkCodeRefs(ctx.latDir);
|
|
277
|
+
const indexErrors = await checkIndex(ctx.latDir);
|
|
112
278
|
const allErrors = [...md.errors, ...code.errors];
|
|
113
279
|
const allFiles = { ...md.files };
|
|
114
280
|
for (const [ext, n] of Object.entries(code.files)) {
|
|
@@ -116,7 +282,10 @@ export async function checkAllCmd(ctx) {
|
|
|
116
282
|
}
|
|
117
283
|
formatStats(ctx, allFiles);
|
|
118
284
|
formatErrors(ctx, allErrors);
|
|
119
|
-
|
|
285
|
+
formatIndexErrors(ctx, indexErrors, allErrors.length);
|
|
286
|
+
const totalErrors = allErrors.length + indexErrors.length;
|
|
287
|
+
formatErrorCount(ctx, totalErrors);
|
|
288
|
+
if (totalErrors > 0)
|
|
120
289
|
process.exit(1);
|
|
121
290
|
console.log(ctx.chalk.green('All checks passed'));
|
|
122
291
|
}
|
package/dist/src/cli/index.js
CHANGED
|
@@ -79,6 +79,14 @@ check
|
|
|
79
79
|
const { checkCodeRefsCmd } = await import('./check.js');
|
|
80
80
|
await checkCodeRefsCmd(ctx);
|
|
81
81
|
});
|
|
82
|
+
check
|
|
83
|
+
.command('index')
|
|
84
|
+
.description('Validate directory index files in lat.md')
|
|
85
|
+
.action(async () => {
|
|
86
|
+
const ctx = resolveContext(program.opts());
|
|
87
|
+
const { checkIndexCmd } = await import('./check.js');
|
|
88
|
+
await checkIndexCmd(ctx);
|
|
89
|
+
});
|
|
82
90
|
program
|
|
83
91
|
.command('prompt')
|
|
84
92
|
.description('Expand [[refs]] in a prompt to lat.md section locations')
|
package/dist/src/cli/prompt.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { relative } from 'node:path';
|
|
2
|
-
import { loadAllSections, findSections, flattenSections, } from '../lattice.js';
|
|
2
|
+
import { loadAllSections, findSections, flattenSections, buildFileIndex, resolveRef, } from '../lattice.js';
|
|
3
3
|
const WIKI_LINK_RE = /\[\[([^\]]+)\]\]/g;
|
|
4
4
|
function formatContext(section, latDir) {
|
|
5
5
|
const relPath = relative(process.cwd(), latDir + '/' + section.file + '.md');
|
|
@@ -13,6 +13,8 @@ function formatContext(section, latDir) {
|
|
|
13
13
|
export async function promptCmd(ctx, text) {
|
|
14
14
|
const allSections = await loadAllSections(ctx.latDir);
|
|
15
15
|
const flat = flattenSections(allSections);
|
|
16
|
+
const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
|
|
17
|
+
const fileIndex = buildFileIndex(allSections);
|
|
16
18
|
const refs = [...text.matchAll(WIKI_LINK_RE)];
|
|
17
19
|
if (refs.length === 0) {
|
|
18
20
|
process.stdout.write(text);
|
|
@@ -23,7 +25,9 @@ export async function promptCmd(ctx, text) {
|
|
|
23
25
|
const target = match[1];
|
|
24
26
|
if (resolved.has(target))
|
|
25
27
|
continue;
|
|
26
|
-
|
|
28
|
+
// Resolve short refs (e.g. search#X → tests/search#X)
|
|
29
|
+
const { resolved: resolvedTarget } = resolveRef(target, sectionIds, fileIndex);
|
|
30
|
+
const q = resolvedTarget.toLowerCase();
|
|
27
31
|
const exact = flat.find((s) => s.id.toLowerCase() === q);
|
|
28
32
|
if (exact) {
|
|
29
33
|
resolved.set(target, exact);
|
package/dist/src/cli/refs.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { listLatticeFiles, loadAllSections, findSections, extractRefs, flattenSections, } from '../lattice.js';
|
|
3
|
+
import { listLatticeFiles, loadAllSections, findSections, extractRefs, flattenSections, buildFileIndex, resolveRef, } from '../lattice.js';
|
|
4
4
|
import { formatSectionPreview } from '../format.js';
|
|
5
5
|
import { scanCodeRefs } from '../code-refs.js';
|
|
6
6
|
export async function refsCmd(ctx, query, scope) {
|
|
@@ -10,9 +10,12 @@ export async function refsCmd(ctx, query, scope) {
|
|
|
10
10
|
console.error(ctx.chalk.red(`No section matching "${query}" (no exact, substring, or fuzzy matches)`));
|
|
11
11
|
process.exit(1);
|
|
12
12
|
}
|
|
13
|
-
//
|
|
14
|
-
const q = query.toLowerCase();
|
|
13
|
+
// Resolve short refs and require exact match
|
|
15
14
|
const flat = flattenSections(allSections);
|
|
15
|
+
const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
|
|
16
|
+
const fileIndex = buildFileIndex(allSections);
|
|
17
|
+
const { resolved } = resolveRef(query, sectionIds, fileIndex);
|
|
18
|
+
const q = resolved.toLowerCase();
|
|
16
19
|
const exactMatch = flat.find((s) => s.id.toLowerCase() === q);
|
|
17
20
|
if (!exactMatch) {
|
|
18
21
|
console.error(ctx.chalk.red(`No section "${query}" found.`));
|
|
@@ -31,9 +34,10 @@ export async function refsCmd(ctx, query, scope) {
|
|
|
31
34
|
const matchingFromSections = new Set();
|
|
32
35
|
for (const file of files) {
|
|
33
36
|
const content = await readFile(file, 'utf-8');
|
|
34
|
-
const fileRefs = extractRefs(file, content);
|
|
37
|
+
const fileRefs = extractRefs(file, content, ctx.latDir);
|
|
35
38
|
for (const ref of fileRefs) {
|
|
36
|
-
|
|
39
|
+
const { resolved: refResolved } = resolveRef(ref.target, sectionIds, fileIndex);
|
|
40
|
+
if (refResolved.toLowerCase() === targetId) {
|
|
37
41
|
matchingFromSections.add(ref.fromSection.toLowerCase());
|
|
38
42
|
}
|
|
39
43
|
}
|
|
@@ -52,7 +56,8 @@ export async function refsCmd(ctx, query, scope) {
|
|
|
52
56
|
const projectRoot = join(ctx.latDir, '..');
|
|
53
57
|
const { refs: codeRefs } = await scanCodeRefs(projectRoot);
|
|
54
58
|
for (const ref of codeRefs) {
|
|
55
|
-
|
|
59
|
+
const { resolved: codeResolved } = resolveRef(ref.target, sectionIds, fileIndex);
|
|
60
|
+
if (codeResolved.toLowerCase() === targetId) {
|
|
56
61
|
if (hasOutput)
|
|
57
62
|
console.log('');
|
|
58
63
|
console.log(` ${ref.file}:${ref.line}`);
|
package/dist/src/code-refs.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/** Walk project files
|
|
1
|
+
/** Walk project files for code-ref scanning. Uses walkEntries for .gitignore
|
|
2
|
+
* support, then additionally skips .md files, lat.md/, .claude/, and sub-projects. */
|
|
2
3
|
export declare function walkFiles(dir: string): Promise<string[]>;
|
|
3
4
|
export declare const LAT_REF_RE: RegExp;
|
|
4
5
|
export type CodeRef = {
|
package/dist/src/code-refs.js
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { walkEntries } from './walk.js';
|
|
4
|
+
/** Walk project files for code-ref scanning. Uses walkEntries for .gitignore
|
|
5
|
+
* support, then additionally skips .md files, lat.md/, .claude/, and sub-projects. */
|
|
6
6
|
export async function walkFiles(dir) {
|
|
7
|
-
const entries = await
|
|
8
|
-
path: dir,
|
|
9
|
-
ignoreFiles: ['.gitignore'],
|
|
10
|
-
});
|
|
7
|
+
const entries = await walkEntries(dir);
|
|
11
8
|
// Collect directories that contain their own lat.md/ (sub-projects)
|
|
12
9
|
const subProjects = new Set();
|
|
13
10
|
for (const e of entries) {
|
|
@@ -17,7 +14,6 @@ export async function walkFiles(dir) {
|
|
|
17
14
|
}
|
|
18
15
|
return entries
|
|
19
16
|
.filter((e) => !e.endsWith('.md') &&
|
|
20
|
-
!e.startsWith('.git/') &&
|
|
21
17
|
!e.startsWith('lat.md/') &&
|
|
22
18
|
!e.startsWith('.claude/') &&
|
|
23
19
|
![...subProjects].some((prefix) => e.startsWith(prefix)))
|
package/dist/src/lattice.d.ts
CHANGED
|
@@ -21,8 +21,29 @@ export declare function parseFrontmatter(content: string): LatFrontmatter;
|
|
|
21
21
|
export declare function stripFrontmatter(content: string): string;
|
|
22
22
|
export declare function findLatticeDir(from?: string): string | null;
|
|
23
23
|
export declare function listLatticeFiles(latticeDir: string): Promise<string[]>;
|
|
24
|
-
export declare function parseSections(filePath: string, content: string): Section[];
|
|
24
|
+
export declare function parseSections(filePath: string, content: string, latticeDir?: string): Section[];
|
|
25
25
|
export declare function loadAllSections(latticeDir: string): Promise<Section[]>;
|
|
26
26
|
export declare function flattenSections(sections: Section[]): Section[];
|
|
27
|
+
/**
|
|
28
|
+
* Build an index mapping bare file stems to their full vault-relative paths.
|
|
29
|
+
* Used by resolveRef to allow short references when a stem is unambiguous.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildFileIndex(sections: Section[]): Map<string, string[]>;
|
|
32
|
+
export type ResolveResult = {
|
|
33
|
+
resolved: string;
|
|
34
|
+
ambiguous: string[] | null;
|
|
35
|
+
/** When ambiguous but exactly one candidate has the section, suggest it. */
|
|
36
|
+
suggested: string | null;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a potentially short reference to its canonical full-path form.
|
|
40
|
+
* If the file segment of the ref is a bare stem that uniquely maps to one
|
|
41
|
+
* full path, expands it. Otherwise returns the ref unchanged.
|
|
42
|
+
*
|
|
43
|
+
* When ambiguous (multiple files share the stem), returns all candidates.
|
|
44
|
+
* If exactly one candidate actually contains the referenced section,
|
|
45
|
+
* `suggested` is set to that candidate so the caller can propose a fix.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveRef(target: string, sectionIds: Set<string>, fileIndex: Map<string, string[]>): ResolveResult;
|
|
27
48
|
export declare function findSections(sections: Section[], query: string): Section[];
|
|
28
|
-
export declare function extractRefs(filePath: string, content: string): Ref[];
|
|
49
|
+
export declare function extractRefs(filePath: string, content: string, latticeDir?: string): Ref[];
|
package/dist/src/lattice.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { dirname, join, basename, resolve } from 'node:path';
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, basename, relative, resolve } from 'node:path';
|
|
3
3
|
import { existsSync, statSync } from 'node:fs';
|
|
4
4
|
import { parse } from './parser.js';
|
|
5
|
+
import { walkEntries } from './walk.js';
|
|
5
6
|
import { visit } from 'unist-util-visit';
|
|
6
7
|
export function parseFrontmatter(content) {
|
|
7
8
|
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
@@ -31,7 +32,7 @@ export function findLatticeDir(from) {
|
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
export async function listLatticeFiles(latticeDir) {
|
|
34
|
-
const entries = await
|
|
35
|
+
const entries = await walkEntries(latticeDir);
|
|
35
36
|
return entries
|
|
36
37
|
.filter((e) => e.endsWith('.md'))
|
|
37
38
|
.sort()
|
|
@@ -63,9 +64,11 @@ function lastLine(content) {
|
|
|
63
64
|
// If trailing newline, count doesn't include empty last line
|
|
64
65
|
return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length;
|
|
65
66
|
}
|
|
66
|
-
export function parseSections(filePath, content) {
|
|
67
|
+
export function parseSections(filePath, content, latticeDir) {
|
|
67
68
|
const tree = parse(stripFrontmatter(content));
|
|
68
|
-
const file =
|
|
69
|
+
const file = latticeDir
|
|
70
|
+
? relative(latticeDir, filePath).replace(/\.md$/, '')
|
|
71
|
+
: basename(filePath, '.md');
|
|
69
72
|
const roots = [];
|
|
70
73
|
const stack = [];
|
|
71
74
|
const flat = [];
|
|
@@ -128,14 +131,28 @@ export function parseSections(filePath, content) {
|
|
|
128
131
|
}
|
|
129
132
|
return roots;
|
|
130
133
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
const sectionsCache = new Map();
|
|
135
|
+
export function loadAllSections(latticeDir) {
|
|
136
|
+
const noCache = !!process.env._LAT_TEST_DISABLE_FS_CACHE;
|
|
137
|
+
const key = resolve(latticeDir);
|
|
138
|
+
if (!noCache) {
|
|
139
|
+
const cached = sectionsCache.get(key);
|
|
140
|
+
if (cached)
|
|
141
|
+
return cached;
|
|
137
142
|
}
|
|
138
|
-
|
|
143
|
+
const result = (async () => {
|
|
144
|
+
const files = await listLatticeFiles(latticeDir);
|
|
145
|
+
const all = [];
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
const content = await readFile(file, 'utf-8');
|
|
148
|
+
all.push(...parseSections(file, content, latticeDir));
|
|
149
|
+
}
|
|
150
|
+
return all;
|
|
151
|
+
})();
|
|
152
|
+
if (!noCache) {
|
|
153
|
+
sectionsCache.set(key, result);
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
139
156
|
}
|
|
140
157
|
export function flattenSections(sections) {
|
|
141
158
|
const result = [];
|
|
@@ -176,11 +193,73 @@ function tailSegments(id) {
|
|
|
176
193
|
}
|
|
177
194
|
return tails;
|
|
178
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Build an index mapping bare file stems to their full vault-relative paths.
|
|
198
|
+
* Used by resolveRef to allow short references when a stem is unambiguous.
|
|
199
|
+
*/
|
|
200
|
+
export function buildFileIndex(sections) {
|
|
201
|
+
const flat = flattenSections(sections);
|
|
202
|
+
const index = new Map();
|
|
203
|
+
for (const s of flat) {
|
|
204
|
+
const stem = s.file.includes('/') ? s.file.split('/').pop() : null;
|
|
205
|
+
if (stem) {
|
|
206
|
+
if (!index.has(stem))
|
|
207
|
+
index.set(stem, new Set());
|
|
208
|
+
index.get(stem).add(s.file);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const result = new Map();
|
|
212
|
+
for (const [stem, paths] of index) {
|
|
213
|
+
result.set(stem, [...paths]);
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Resolve a potentially short reference to its canonical full-path form.
|
|
219
|
+
* If the file segment of the ref is a bare stem that uniquely maps to one
|
|
220
|
+
* full path, expands it. Otherwise returns the ref unchanged.
|
|
221
|
+
*
|
|
222
|
+
* When ambiguous (multiple files share the stem), returns all candidates.
|
|
223
|
+
* If exactly one candidate actually contains the referenced section,
|
|
224
|
+
* `suggested` is set to that candidate so the caller can propose a fix.
|
|
225
|
+
*/
|
|
226
|
+
export function resolveRef(target, sectionIds, fileIndex) {
|
|
227
|
+
// Already matches a known section — no resolution needed
|
|
228
|
+
if (sectionIds.has(target.toLowerCase())) {
|
|
229
|
+
return { resolved: target, ambiguous: null, suggested: null };
|
|
230
|
+
}
|
|
231
|
+
// Extract the file segment (before first #) and try resolving it
|
|
232
|
+
const hashIdx = target.indexOf('#');
|
|
233
|
+
const filePart = hashIdx === -1 ? target : target.slice(0, hashIdx);
|
|
234
|
+
const rest = hashIdx === -1 ? '' : target.slice(hashIdx);
|
|
235
|
+
const candidates = fileIndex.get(filePart) ?? [];
|
|
236
|
+
if (candidates.length === 1) {
|
|
237
|
+
const expanded = candidates[0] + rest;
|
|
238
|
+
if (sectionIds.has(expanded.toLowerCase())) {
|
|
239
|
+
return { resolved: expanded, ambiguous: null, suggested: null };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
else if (candidates.length > 1) {
|
|
243
|
+
// Multiple files share this stem — ambiguous at the filename level
|
|
244
|
+
const all = candidates.map((c) => c + rest);
|
|
245
|
+
const valid = candidates.filter((c) => sectionIds.has((c + rest).toLowerCase()));
|
|
246
|
+
return {
|
|
247
|
+
resolved: target,
|
|
248
|
+
ambiguous: all,
|
|
249
|
+
suggested: valid.length === 1 ? valid[0] + rest : null,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return { resolved: target, ambiguous: null, suggested: null };
|
|
253
|
+
}
|
|
179
254
|
const MAX_DISTANCE_RATIO = 0.4;
|
|
180
255
|
export function findSections(sections, query) {
|
|
181
256
|
const flat = flattenSections(sections);
|
|
182
|
-
const q = query.toLowerCase();
|
|
183
257
|
const isFullPath = query.includes('#');
|
|
258
|
+
// Tier 0: resolve short refs (e.g. search#X → tests/search#X)
|
|
259
|
+
const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
|
|
260
|
+
const fileIndex = buildFileIndex(sections);
|
|
261
|
+
const { resolved } = resolveRef(query, sectionIds, fileIndex);
|
|
262
|
+
const q = resolved.toLowerCase();
|
|
184
263
|
// Tier 1: exact full-id match
|
|
185
264
|
const exact = flat.filter((s) => s.id.toLowerCase() === q);
|
|
186
265
|
if (exact.length > 0 && isFullPath)
|
|
@@ -214,9 +293,11 @@ export function findSections(sections, query) {
|
|
|
214
293
|
fuzzy.sort((a, b) => a.distance - b.distance);
|
|
215
294
|
return [...exact, ...subsection, ...fuzzy.map((f) => f.section)];
|
|
216
295
|
}
|
|
217
|
-
export function extractRefs(filePath, content) {
|
|
296
|
+
export function extractRefs(filePath, content, latticeDir) {
|
|
218
297
|
const tree = parse(stripFrontmatter(content));
|
|
219
|
-
const file =
|
|
298
|
+
const file = latticeDir
|
|
299
|
+
? relative(latticeDir, filePath).replace(/\.md$/, '')
|
|
300
|
+
: basename(filePath, '.md');
|
|
220
301
|
const refs = [];
|
|
221
302
|
// Build a flat list of sections to determine enclosing section for each wiki link
|
|
222
303
|
const flat = [];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk a directory tree respecting .gitignore rules. Returns relative paths
|
|
3
|
+
* of all non-ignored files, excluding .git/ and dotfiles (e.g. .gitignore).
|
|
4
|
+
*
|
|
5
|
+
* Results are memoized by resolved directory path — safe because CLI commands
|
|
6
|
+
* don't modify the filesystem during a run. Set _LAT_TEST_DISABLE_FS_CACHE=1
|
|
7
|
+
* to bypass caching in tests that mutate the filesystem mid-run.
|
|
8
|
+
*
|
|
9
|
+
* This is the single entry point for all directory walking in lat.md — both
|
|
10
|
+
* code-ref scanning and lat.md/ index validation use it so .gitignore rules
|
|
11
|
+
* are consistently honored.
|
|
12
|
+
*/
|
|
13
|
+
export declare function walkEntries(dir: string): Promise<string[]>;
|
package/dist/src/walk.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
// @ts-expect-error -- no type declarations
|
|
3
|
+
import walk from 'ignore-walk';
|
|
4
|
+
const cache = new Map();
|
|
5
|
+
/**
|
|
6
|
+
* Walk a directory tree respecting .gitignore rules. Returns relative paths
|
|
7
|
+
* of all non-ignored files, excluding .git/ and dotfiles (e.g. .gitignore).
|
|
8
|
+
*
|
|
9
|
+
* Results are memoized by resolved directory path — safe because CLI commands
|
|
10
|
+
* don't modify the filesystem during a run. Set _LAT_TEST_DISABLE_FS_CACHE=1
|
|
11
|
+
* to bypass caching in tests that mutate the filesystem mid-run.
|
|
12
|
+
*
|
|
13
|
+
* This is the single entry point for all directory walking in lat.md — both
|
|
14
|
+
* code-ref scanning and lat.md/ index validation use it so .gitignore rules
|
|
15
|
+
* are consistently honored.
|
|
16
|
+
*/
|
|
17
|
+
export function walkEntries(dir) {
|
|
18
|
+
const noCache = !!process.env._LAT_TEST_DISABLE_FS_CACHE;
|
|
19
|
+
if (!noCache) {
|
|
20
|
+
const cached = cache.get(resolve(dir));
|
|
21
|
+
if (cached)
|
|
22
|
+
return cached;
|
|
23
|
+
}
|
|
24
|
+
const result = walk({
|
|
25
|
+
path: dir,
|
|
26
|
+
ignoreFiles: ['.gitignore'],
|
|
27
|
+
}).then((entries) => entries.filter((e) => !e.startsWith('.git/') && !e.startsWith('.')));
|
|
28
|
+
if (!noCache) {
|
|
29
|
+
cache.set(resolve(dir), result);
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
package/package.json
CHANGED
package/templates/AGENTS.md
CHANGED
|
@@ -33,7 +33,7 @@ If `lat search` fails because `LAT_LLM_KEY` is not set, explain to the user that
|
|
|
33
33
|
|
|
34
34
|
# Syntax primer
|
|
35
35
|
|
|
36
|
-
- **Section ids**: `file
|
|
36
|
+
- **Section ids**: `path/to/file#Heading#SubHeading` — full form uses vault-relative path (e.g. `tests/search#RAG Replay Tests`). Short form uses bare file name when unique (e.g. `search#RAG Replay Tests`, `cli#search#Indexing`).
|
|
37
37
|
- **Wiki links**: `[[target]]` or `[[target|alias]]` — cross-references between sections
|
|
38
38
|
- **Code refs**: `// @lat: [[section-id]]` (JS/TS) or `# @lat: [[section-id]]` (Python) — ties source code to concepts
|
|
39
39
|
|
|
@@ -50,9 +50,14 @@ lat:
|
|
|
50
50
|
|
|
51
51
|
## User login
|
|
52
52
|
### Rejects expired tokens
|
|
53
|
+
Tokens past their expiry timestamp are rejected with 401, even if otherwise valid.
|
|
54
|
+
|
|
53
55
|
### Handles missing password
|
|
56
|
+
Login request without a password field returns 400 with a descriptive error.
|
|
54
57
|
```
|
|
55
58
|
|
|
59
|
+
Every section MUST have a description — at least one sentence explaining what the test verifies and why. Empty sections with just a heading are not acceptable.
|
|
60
|
+
|
|
56
61
|
Each test in code should reference its spec with exactly one comment placed next to the relevant test — not at the top of the file:
|
|
57
62
|
|
|
58
63
|
```python
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This directory defines the high-level concepts, business logic, and architecture of this project using markdown. It is managed by [lat.md](https://www.npmjs.com/package/lat.md) — a tool that anchors source code to these definitions. Install the `lat` command with `npm i -g lat.md` and run `lat --help`.
|
package/dist/src/cli.d.ts
DELETED
package/dist/src/cli.js
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { findLatticeDir, loadAllSections, findSections } from './lattice.js';
|
|
3
|
-
const args = process.argv.slice(2);
|
|
4
|
-
const command = args[0];
|
|
5
|
-
if (command !== 'locate' || args.length < 2) {
|
|
6
|
-
console.error('Usage: lat locate <query>');
|
|
7
|
-
process.exit(1);
|
|
8
|
-
}
|
|
9
|
-
const query = args[1];
|
|
10
|
-
const latticeDir = findLatticeDir();
|
|
11
|
-
if (!latticeDir) {
|
|
12
|
-
console.error('No .lattice directory found');
|
|
13
|
-
process.exit(1);
|
|
14
|
-
}
|
|
15
|
-
const sections = await loadAllSections(latticeDir);
|
|
16
|
-
const matches = findSections(sections, query);
|
|
17
|
-
if (matches.length === 0) {
|
|
18
|
-
console.error(`No sections matching "${query}"`);
|
|
19
|
-
process.exit(1);
|
|
20
|
-
}
|
|
21
|
-
for (const m of matches) {
|
|
22
|
-
console.log(m.id);
|
|
23
|
-
}
|
package/templates/init/README.md
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
This directory defines the high-level concepts, business logic, and architecture of this project using markdown.
|
|
2
|
-
|
|
3
|
-
It is managed by [lat.md](https://www.npmjs.com/package/lat.md) — a tool that anchors source code to these definitions.
|
|
4
|
-
|
|
5
|
-
Install the `lat` command with `npm i -g lat.md` and run `lat --help`.
|