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 CHANGED
@@ -1,6 +1,7 @@
1
1
  # lat.md
2
2
 
3
3
  [![CI](https://github.com/1st1/lat.md/actions/workflows/ci.yml/badge.svg)](https://github.com/1st1/lat.md/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/lat.md)](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
 
@@ -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>;
@@ -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 target = ref.target.toLowerCase();
25
- if (!sectionIds.has(target)) {
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 target = ref.target.toLowerCase();
47
- mentionedSections.add(target);
48
- if (!sectionIds.has(target)) {
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
- function formatErrors(ctx, errors) {
81
- for (const err of errors) {
82
- console.error(`${ctx.chalk.cyan(err.file + ':' + err.line)}: ${ctx.chalk.red(err.message)}`);
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
- if (errors.length > 0) {
85
- console.error(ctx.chalk.red(`\n${errors.length} error${errors.length === 1 ? '' : 's'} found`));
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
- if (allErrors.length > 0)
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
  }
@@ -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')
@@ -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
- const q = target.toLowerCase();
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);
@@ -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
- // Require exact full-path match
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
- if (ref.target.toLowerCase() === targetId) {
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
- if (ref.target.toLowerCase() === targetId) {
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}`);
@@ -1,4 +1,5 @@
1
- /** Walk project files respecting .gitignore. Skips lat.md/, .claude/, and sub-projects. */
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 = {
@@ -1,13 +1,10 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join, relative } from 'node:path';
3
- // @ts-expect-error -- no type declarations
4
- import walk from 'ignore-walk';
5
- /** Walk project files respecting .gitignore. Skips lat.md/, .claude/, and sub-projects. */
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 walk({
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)))
@@ -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[];
@@ -1,7 +1,8 @@
1
- import { readdir, readFile } from 'node:fs/promises';
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 readdir(latticeDir);
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 = basename(filePath, '.md');
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
- export async function loadAllSections(latticeDir) {
132
- const files = await listLatticeFiles(latticeDir);
133
- const all = [];
134
- for (const file of files) {
135
- const content = await readFile(file, 'utf-8');
136
- all.push(...parseSections(file, content));
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
- return all;
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 = basename(filePath, '.md');
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[]>;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lat.md",
3
- "version": "0.1.3",
3
+ "version": "0.2.3",
4
4
  "description": "A knowledge graph for your codebase, written in markdown",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.30.2",
@@ -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-stem#Heading#SubHeading` (e.g. `cli#search#Indexing`)
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
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
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
- }
@@ -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`.