lat.md 0.1.4 → 0.3.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/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,4 +1,4 @@
1
- import { existsSync, cpSync, mkdirSync, writeFileSync, symlinkSync, } from 'node:fs';
1
+ import { existsSync, cpSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, chmodSync, symlinkSync, } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { createInterface } from 'node:readline/promises';
4
4
  import chalk from 'chalk';
@@ -13,6 +13,49 @@ async function confirm(rl, message) {
13
13
  return true;
14
14
  }
15
15
  }
16
+ const HOOK_COMMAND = '.claude/hooks/lat-prompt-hook.sh';
17
+ /**
18
+ * Check if .claude/settings.json already has the lat-prompt hook configured.
19
+ */
20
+ function hasLatHook(settingsPath) {
21
+ if (!existsSync(settingsPath))
22
+ return false;
23
+ try {
24
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
25
+ const entries = settings?.hooks?.UserPromptSubmit;
26
+ if (!Array.isArray(entries))
27
+ return false;
28
+ return entries.some((entry) => entry.hooks?.some((h) => h.command === HOOK_COMMAND));
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ /**
35
+ * Add the lat-prompt hook to .claude/settings.json, preserving existing config.
36
+ */
37
+ function addLatHook(settingsPath) {
38
+ let settings = {};
39
+ if (existsSync(settingsPath)) {
40
+ try {
41
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
42
+ }
43
+ catch {
44
+ // Corrupted file — start fresh
45
+ }
46
+ }
47
+ if (!settings.hooks || typeof settings.hooks !== 'object') {
48
+ settings.hooks = {};
49
+ }
50
+ const hooks = settings.hooks;
51
+ if (!Array.isArray(hooks.UserPromptSubmit)) {
52
+ hooks.UserPromptSubmit = [];
53
+ }
54
+ hooks.UserPromptSubmit.push({
55
+ hooks: [{ type: 'command', command: HOOK_COMMAND }],
56
+ });
57
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
58
+ }
16
59
  export async function initCmd(targetDir) {
17
60
  const root = resolve(targetDir ?? process.cwd());
18
61
  const latDir = join(root, 'lat.md');
@@ -60,6 +103,31 @@ export async function initCmd(targetDir) {
60
103
  console.log(`\n${existing} already exists. Run ${chalk.cyan('lat gen agents.md')} to preview the template,` +
61
104
  ` then incorporate its content or overwrite as needed.`);
62
105
  }
106
+ // Step 3: Claude Code prompt hook
107
+ const claudeDir = join(root, '.claude');
108
+ const hooksDir = join(claudeDir, 'hooks');
109
+ const hookPath = join(hooksDir, 'lat-prompt-hook.sh');
110
+ const settingsPath = join(claudeDir, 'settings.json');
111
+ if (hasLatHook(settingsPath)) {
112
+ console.log(chalk.green('Claude Code hook') + ' already configured');
113
+ }
114
+ else {
115
+ console.log('');
116
+ console.log(chalk.bold('Claude Code hook') +
117
+ ' — adds a per-prompt reminder for the agent to consult lat.md');
118
+ console.log(chalk.dim(' Creates .claude/hooks/lat-prompt-hook.sh and registers it in .claude/settings.json'));
119
+ console.log(chalk.dim(' On every prompt, the agent is instructed to run `lat search` and `lat prompt` before working.'));
120
+ if (await ask('Set up Claude Code prompt hook?')) {
121
+ mkdirSync(hooksDir, { recursive: true });
122
+ const templateHook = join(findTemplatesDir(), 'lat-prompt-hook.sh');
123
+ copyFileSync(templateHook, hookPath);
124
+ chmodSync(hookPath, 0o755);
125
+ addLatHook(settingsPath);
126
+ console.log(chalk.green('Created .claude/hooks/lat-prompt-hook.sh'));
127
+ console.log(chalk.green('Updated .claude/settings.json') +
128
+ ' with UserPromptSubmit hook');
129
+ }
130
+ }
63
131
  }
64
132
  finally {
65
133
  rl?.close();
@@ -1,13 +1,12 @@
1
1
  import { loadAllSections, findSections } from '../lattice.js';
2
2
  import { formatResultList } from '../format.js';
3
3
  export async function locateCmd(ctx, query) {
4
+ const stripped = query.replace(/^\[\[|\]\]$/g, '');
4
5
  const sections = await loadAllSections(ctx.latDir);
5
- const matches = findSections(sections, query);
6
+ const matches = findSections(sections, stripped);
6
7
  if (matches.length === 0) {
7
- console.error(ctx.chalk.red(`No sections matching "${query}" (no exact, substring, or fuzzy matches)`));
8
+ console.error(ctx.chalk.red(`No sections matching "${stripped}" (no exact, substring, or fuzzy matches)`));
8
9
  process.exit(1);
9
10
  }
10
- console.log(formatResultList(`Sections matching "${query}":`, matches, ctx.latDir, {
11
- numbered: true,
12
- }));
11
+ console.log(formatResultList(`Sections matching "${stripped}":`, matches, ctx.latDir));
13
12
  }
@@ -1,18 +1,12 @@
1
1
  import { relative } from 'node:path';
2
- import { loadAllSections, findSections, flattenSections, } from '../lattice.js';
2
+ import { loadAllSections, findSections, } from '../lattice.js';
3
3
  const WIKI_LINK_RE = /\[\[([^\]]+)\]\]/g;
4
- function formatContext(section, latDir) {
4
+ function formatLocation(section, latDir) {
5
5
  const relPath = relative(process.cwd(), latDir + '/' + section.file + '.md');
6
- const loc = `${relPath}:${section.startLine}-${section.endLine}`;
7
- let text = `[${section.id}](${loc})`;
8
- if (section.body) {
9
- text += `: ${section.body}`;
10
- }
11
- return text;
6
+ return `${relPath}:${section.startLine}-${section.endLine}`;
12
7
  }
13
8
  export async function promptCmd(ctx, text) {
14
9
  const allSections = await loadAllSections(ctx.latDir);
15
- const flat = flattenSections(allSections);
16
10
  const refs = [...text.matchAll(WIKI_LINK_RE)];
17
11
  if (refs.length === 0) {
18
12
  process.stdout.write(text);
@@ -23,39 +17,43 @@ export async function promptCmd(ctx, text) {
23
17
  const target = match[1];
24
18
  if (resolved.has(target))
25
19
  continue;
26
- const q = target.toLowerCase();
27
- const exact = flat.find((s) => s.id.toLowerCase() === q);
28
- if (exact) {
29
- resolved.set(target, exact);
30
- continue;
31
- }
32
- const fuzzy = findSections(allSections, target);
33
- if (fuzzy.length === 1) {
34
- resolved.set(target, fuzzy[0]);
20
+ const matches = findSections(allSections, target);
21
+ if (matches.length >= 1) {
22
+ resolved.set(target, {
23
+ target,
24
+ best: matches[0],
25
+ alternatives: matches.slice(1),
26
+ });
35
27
  continue;
36
28
  }
37
- if (fuzzy.length > 1) {
38
- console.error(ctx.chalk.red(`Ambiguous reference [[${target}]].`));
39
- console.error(ctx.chalk.dim('\nCould match:\n'));
40
- for (const m of fuzzy) {
41
- console.error(' ' + m.id);
42
- }
43
- console.error(ctx.chalk.dim('\nAsk the user which section they meant.'));
44
- process.exit(1);
45
- }
46
29
  console.error(ctx.chalk.red(`No section found for [[${target}]] (no exact, substring, or fuzzy matches).`));
47
30
  console.error(ctx.chalk.dim('Ask the user to correct the reference.'));
48
31
  process.exit(1);
49
32
  }
50
33
  // Replace [[refs]] inline
51
34
  let output = text.replace(WIKI_LINK_RE, (_match, target) => {
52
- const section = resolved.get(target);
53
- return `[[${section.id}]]`;
35
+ const ref = resolved.get(target);
36
+ return `[[${ref.best.section.id}]]`;
54
37
  });
55
- // Append context block
38
+ // Append context block as nested outliner
56
39
  output += '\n\n<lat-context>\n';
57
- for (const section of resolved.values()) {
58
- output += formatContext(section, ctx.latDir) + '\n';
40
+ for (const ref of resolved.values()) {
41
+ const isExact = ref.best.reason === 'exact match';
42
+ const all = isExact ? [ref.best] : [ref.best, ...ref.alternatives];
43
+ if (isExact) {
44
+ output += `* \`[[${ref.target}]]\` is referring to:\n`;
45
+ }
46
+ else {
47
+ output += `* \`[[${ref.target}]]\` might be referring to either of the following:\n`;
48
+ }
49
+ for (const m of all) {
50
+ const reason = isExact ? '' : ` (${m.reason})`;
51
+ output += ` * [[${m.section.id}]]${reason}\n`;
52
+ output += ` * ${formatLocation(m.section, ctx.latDir)}\n`;
53
+ if (m.section.body) {
54
+ output += ` * ${m.section.body}\n`;
55
+ }
56
+ }
59
57
  }
60
58
  output += '</lat-context>\n';
61
59
  process.stdout.write(output);
@@ -1,7 +1,7 @@
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';
4
- import { formatSectionPreview } from '../format.js';
3
+ import { listLatticeFiles, loadAllSections, findSections, extractRefs, flattenSections, buildFileIndex, resolveRef, } from '../lattice.js';
4
+ import { formatResultList } from '../format.js';
5
5
  import { scanCodeRefs } from '../code-refs.js';
6
6
  export async function refsCmd(ctx, query, scope) {
7
7
  const allSections = await loadAllSections(ctx.latDir);
@@ -10,41 +10,47 @@ 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.`));
19
22
  if (matches.length > 0) {
20
23
  console.error(ctx.chalk.dim('\nDid you mean:\n'));
21
24
  for (const m of matches) {
22
- console.error(' ' + ctx.chalk.white(m.id));
25
+ console.error(ctx.chalk.dim('*') +
26
+ ' ' +
27
+ ctx.chalk.white(m.section.id) +
28
+ ' ' +
29
+ ctx.chalk.dim(`(${m.reason})`));
23
30
  }
24
31
  }
25
32
  process.exit(1);
26
33
  }
27
34
  const targetId = exactMatch.id.toLowerCase();
28
- let hasOutput = false;
35
+ const mdMatches = [];
36
+ const codeLines = [];
29
37
  if (scope === 'md' || scope === 'md+code') {
30
38
  const files = await listLatticeFiles(ctx.latDir);
31
39
  const matchingFromSections = new Set();
32
40
  for (const file of files) {
33
41
  const content = await readFile(file, 'utf-8');
34
- const fileRefs = extractRefs(file, content);
42
+ const fileRefs = extractRefs(file, content, ctx.latDir);
35
43
  for (const ref of fileRefs) {
36
- if (ref.target.toLowerCase() === targetId) {
44
+ const { resolved: refResolved } = resolveRef(ref.target, sectionIds, fileIndex);
45
+ if (refResolved.toLowerCase() === targetId) {
37
46
  matchingFromSections.add(ref.fromSection.toLowerCase());
38
47
  }
39
48
  }
40
49
  }
41
50
  if (matchingFromSections.size > 0) {
42
51
  const referrers = flat.filter((s) => matchingFromSections.has(s.id.toLowerCase()));
43
- for (let i = 0; i < referrers.length; i++) {
44
- if (hasOutput)
45
- console.log('');
46
- console.log(formatSectionPreview(referrers[i], ctx.latDir));
47
- hasOutput = true;
52
+ for (const s of referrers) {
53
+ mdMatches.push({ section: s, reason: 'wiki link' });
48
54
  }
49
55
  }
50
56
  }
@@ -52,16 +58,26 @@ export async function refsCmd(ctx, query, scope) {
52
58
  const projectRoot = join(ctx.latDir, '..');
53
59
  const { refs: codeRefs } = await scanCodeRefs(projectRoot);
54
60
  for (const ref of codeRefs) {
55
- if (ref.target.toLowerCase() === targetId) {
56
- if (hasOutput)
57
- console.log('');
58
- console.log(` ${ref.file}:${ref.line}`);
59
- hasOutput = true;
61
+ const { resolved: codeResolved } = resolveRef(ref.target, sectionIds, fileIndex);
62
+ if (codeResolved.toLowerCase() === targetId) {
63
+ codeLines.push(`${ref.file}:${ref.line}`);
60
64
  }
61
65
  }
62
66
  }
63
- if (!hasOutput) {
67
+ if (mdMatches.length === 0 && codeLines.length === 0) {
64
68
  console.error(ctx.chalk.red(`No references to "${exactMatch.id}" found`));
65
69
  process.exit(1);
66
70
  }
71
+ if (mdMatches.length > 0) {
72
+ console.log(formatResultList(`References to "${exactMatch.id}":`, mdMatches, ctx.latDir));
73
+ }
74
+ if (codeLines.length > 0) {
75
+ if (mdMatches.length > 0)
76
+ console.log('');
77
+ console.log(ctx.chalk.bold('Code references:'));
78
+ console.log('');
79
+ for (const line of codeLines) {
80
+ console.log(`${ctx.chalk.dim('*')} ${line}`);
81
+ }
82
+ }
67
83
  }
@@ -44,10 +44,9 @@ export async function searchCmd(ctx, query, opts) {
44
44
  const byId = new Map(flat.map((s) => [s.id, s]));
45
45
  const matched = results
46
46
  .map((r) => byId.get(r.id))
47
- .filter((s) => !!s);
48
- console.log(formatResultList(`Search results for "${query}":`, matched, ctx.latDir, {
49
- numbered: true,
50
- }));
47
+ .filter((s) => !!s)
48
+ .map((s) => ({ section: s, reason: 'semantic match' }));
49
+ console.log(formatResultList(`Search results for "${query}":`, matched, ctx.latDir));
51
50
  }
52
51
  finally {
53
52
  await closeDb(db);
@@ -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)))
@@ -1,8 +1,6 @@
1
- import type { Section } from './lattice.js';
1
+ import type { Section, SectionMatch } from './lattice.js';
2
2
  export declare function formatSectionId(id: string): string;
3
3
  export declare function formatSectionPreview(section: Section, latticeDir: string, opts?: {
4
- index?: number;
5
- }): string;
6
- export declare function formatResultList(header: string, sections: Section[], latticeDir: string, opts?: {
7
- numbered?: boolean;
4
+ reason?: string;
8
5
  }): string;
6
+ export declare function formatResultList(header: string, matches: SectionMatch[], latticeDir: string): string;
@@ -9,28 +9,28 @@ export function formatSectionId(id) {
9
9
  }
10
10
  export function formatSectionPreview(section, latticeDir, opts) {
11
11
  const relPath = relative(process.cwd(), latticeDir + '/' + section.file + '.md');
12
- const prefix = opts?.index != null ? `${chalk.dim(`${opts.index}.`)} ` : ' ';
13
- const indent = opts?.index != null ? ' ' : ' ';
12
+ const kind = section.id.includes('#') ? 'Section' : 'File';
13
+ const reasonSuffix = opts?.reason ? ' ' + chalk.dim(`(${opts.reason})`) : '';
14
14
  const lines = [
15
- `${prefix}${formatSectionId(section.id)}`,
16
- `${indent}${chalk.dim('Defined in')} ${chalk.cyan(relPath)}${chalk.dim(`:${section.startLine}-${section.endLine}`)}`,
15
+ `${chalk.dim('*')} ${chalk.dim(kind + ':')} [[${formatSectionId(section.id)}]]${reasonSuffix}`,
16
+ ` ${chalk.dim('Defined in')} ${chalk.cyan(relPath)}${chalk.dim(`:${section.startLine}-${section.endLine}`)}`,
17
17
  ];
18
18
  if (section.body) {
19
19
  const truncated = section.body.length > 200
20
20
  ? section.body.slice(0, 200) + '...'
21
21
  : section.body;
22
22
  lines.push('');
23
- lines.push(`${indent}${chalk.dim('>')} ${truncated}`);
23
+ lines.push(` ${chalk.dim('>')} ${truncated}`);
24
24
  }
25
25
  return lines.join('\n');
26
26
  }
27
- export function formatResultList(header, sections, latticeDir, opts) {
27
+ export function formatResultList(header, matches, latticeDir) {
28
28
  const lines = ['', chalk.bold(header), ''];
29
- for (let i = 0; i < sections.length; i++) {
29
+ for (let i = 0; i < matches.length; i++) {
30
30
  if (i > 0)
31
31
  lines.push('');
32
- lines.push(formatSectionPreview(sections[i], latticeDir, {
33
- index: opts?.numbered ? i + 1 : undefined,
32
+ lines.push(formatSectionPreview(matches[i].section, latticeDir, {
33
+ reason: matches[i].reason,
34
34
  }));
35
35
  }
36
36
  lines.push('');
@@ -21,8 +21,33 @@ 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
- export declare function findSections(sections: Section[], query: string): Section[];
28
- export declare function extractRefs(filePath: string, content: string): Ref[];
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;
48
+ export type SectionMatch = {
49
+ section: Section;
50
+ reason: string;
51
+ };
52
+ export declare function findSections(sections: Section[], query: string): SectionMatch[];
53
+ 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,47 +193,231 @@ 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
- const isFullPath = query.includes('#');
257
+ // Leading # means "search for a heading", strip it
258
+ const normalized = query.startsWith('#') ? query.slice(1) : query;
259
+ const q = normalized.toLowerCase();
260
+ const isFullPath = normalized.includes('#');
184
261
  // Tier 1: exact full-id match
185
262
  const exact = flat.filter((s) => s.id.toLowerCase() === q);
186
- if (exact.length > 0 && isFullPath)
187
- return exact;
263
+ const exactMatches = exact.map((s) => ({
264
+ section: s,
265
+ reason: 'exact match',
266
+ }));
267
+ if (exactMatches.length > 0 && isFullPath)
268
+ return exactMatches;
269
+ // Tier 1b: file stem expansion
270
+ // For bare names: "locate" → matches root section of "tests/locate.md"
271
+ // For paths with #: "setup#Install" → expands to "guides/setup#Install"
272
+ const fileIndex = buildFileIndex(sections);
273
+ const stemMatches = [];
274
+ if (isFullPath) {
275
+ // Expand file stem in the file part of the query
276
+ const hashIdx = normalized.indexOf('#');
277
+ const filePart = normalized.slice(0, hashIdx);
278
+ const rest = normalized.slice(hashIdx);
279
+ const paths = fileIndex.get(filePart) ?? [];
280
+ for (const p of paths) {
281
+ const expanded = (p + rest).toLowerCase();
282
+ const s = flat.find((s) => s.id.toLowerCase() === expanded && !exact.includes(s));
283
+ if (s)
284
+ stemMatches.push({
285
+ section: s,
286
+ reason: `file stem expanded: ${filePart} → ${p}`,
287
+ });
288
+ }
289
+ if (stemMatches.length > 0)
290
+ return [...exactMatches, ...stemMatches];
291
+ }
292
+ else {
293
+ // Bare name: match file root sections via stem index
294
+ const paths = fileIndex.get(normalized) ?? [];
295
+ for (const p of paths) {
296
+ const s = flat.find((s) => s.id.toLowerCase() === p.toLowerCase() && !exact.includes(s));
297
+ if (s)
298
+ stemMatches.push({ section: s, reason: 'file stem match' });
299
+ }
300
+ }
188
301
  // Tier 2: exact match on trailing segments (subsection name match)
302
+ const seen = new Set([
303
+ ...exact.map((s) => s.id),
304
+ ...stemMatches.map((m) => m.section.id),
305
+ ]);
189
306
  const subsection = isFullPath
190
307
  ? []
191
- : flat.filter((s) => tailSegments(s.id).some((tail) => tail.toLowerCase() === q));
308
+ : flat
309
+ .filter((s) => {
310
+ if (seen.has(s.id))
311
+ return false;
312
+ return tailSegments(s.id).some((tail) => tail.toLowerCase() === q);
313
+ })
314
+ .map((s) => ({ section: s, reason: 'section name match' }));
315
+ // Tier 2b: subsequence match — query segments are a subsequence of section id segments
316
+ // e.g. "Markdown#Resolution Rules" matches "markdown#Wiki Links#Resolution Rules"
317
+ const seenSub = new Set([...seen, ...subsection.map((m) => m.section.id)]);
318
+ const qParts = q.split('#');
319
+ const subsequence = qParts.length >= 2
320
+ ? flat
321
+ .filter((s) => {
322
+ if (seenSub.has(s.id))
323
+ return false;
324
+ const sParts = s.id.toLowerCase().split('#');
325
+ if (sParts.length <= qParts.length)
326
+ return false;
327
+ let qi = 0;
328
+ for (const sp of sParts) {
329
+ if (sp === qParts[qi])
330
+ qi++;
331
+ if (qi === qParts.length)
332
+ return true;
333
+ }
334
+ return false;
335
+ })
336
+ .map((s) => {
337
+ const skipped = s.id.split('#').length - qParts.length;
338
+ return {
339
+ section: s,
340
+ reason: `path match, ${skipped} intermediate ${skipped === 1 ? 'section' : 'sections'} skipped`,
341
+ };
342
+ })
343
+ : [];
192
344
  // Tier 3: fuzzy match by edit distance on each segment tail and full id
193
- const seen = new Set([
194
- ...exact.map((s) => s.id),
195
- ...subsection.map((s) => s.id),
345
+ const seenAll = new Set([
346
+ ...seenSub,
347
+ ...subsequence.map((m) => m.section.id),
196
348
  ]);
197
349
  const fuzzy = [];
350
+ // For full-path queries, extract the file and heading parts so we can
351
+ // fuzzy-match only the heading portion when the file part matches exactly.
352
+ // This prevents the shared file prefix from inflating similarity scores
353
+ // (e.g. "cli#locat" would otherwise fuzzy-match "cli#prompt").
354
+ const qHashIdx = normalized.indexOf('#');
355
+ const qFile = qHashIdx === -1 ? null : normalized.slice(0, qHashIdx).toLowerCase();
356
+ const qHeading = qHashIdx === -1 ? null : normalized.slice(qHashIdx + 1).toLowerCase();
198
357
  for (const s of flat) {
199
- if (seen.has(s.id))
358
+ if (seenAll.has(s.id))
200
359
  continue;
201
360
  const candidates = [s.id, ...tailSegments(s.id)];
202
361
  let best = Infinity;
362
+ let bestCandidate = '';
203
363
  for (const c of candidates) {
204
- const d = levenshtein(c.toLowerCase(), q);
205
- const maxLen = Math.max(c.length, q.length);
206
- if (d / maxLen <= MAX_DISTANCE_RATIO && d < best) {
364
+ let d;
365
+ let maxLen;
366
+ const cl = c.toLowerCase();
367
+ const cHashIdx = cl.indexOf('#');
368
+ // When both query and candidate have # and their file parts match,
369
+ // compare only the heading portions to avoid file-prefix inflation
370
+ if (qFile && qHeading && cHashIdx !== -1) {
371
+ const cFile = cl.slice(0, cHashIdx);
372
+ const cHeading = cl.slice(cHashIdx + 1);
373
+ if (cFile === qFile) {
374
+ d = levenshtein(cHeading, qHeading);
375
+ maxLen = Math.max(cHeading.length, qHeading.length);
376
+ }
377
+ else {
378
+ d = levenshtein(cl, q);
379
+ maxLen = Math.max(c.length, q.length);
380
+ }
381
+ }
382
+ else {
383
+ d = levenshtein(cl, q);
384
+ maxLen = Math.max(c.length, q.length);
385
+ }
386
+ if (maxLen > 0 && d / maxLen <= MAX_DISTANCE_RATIO && d < best) {
207
387
  best = d;
388
+ bestCandidate = c;
208
389
  }
209
390
  }
210
391
  if (best < Infinity) {
211
- fuzzy.push({ section: s, distance: best });
392
+ fuzzy.push({ section: s, distance: best, matched: bestCandidate });
212
393
  }
213
394
  }
214
395
  fuzzy.sort((a, b) => a.distance - b.distance);
215
- return [...exact, ...subsection, ...fuzzy.map((f) => f.section)];
396
+ const fuzzyMatches = fuzzy.map((f) => ({
397
+ section: f.section,
398
+ reason: f.matched.toLowerCase() === f.section.id.toLowerCase()
399
+ ? `fuzzy match, distance ${f.distance}`
400
+ : `fuzzy match on "${f.matched}", distance ${f.distance}`,
401
+ }));
402
+ // Sort results: shallower depth first, then fewer path segments
403
+ const sortKey = (s) => {
404
+ const pathDepth = (s.file.match(/\//g) || []).length;
405
+ return s.depth * 100 + pathDepth;
406
+ };
407
+ const sortedStems = [...stemMatches].sort((a, b) => sortKey(a.section) - sortKey(b.section));
408
+ return [
409
+ ...exactMatches,
410
+ ...sortedStems,
411
+ ...subsection,
412
+ ...subsequence,
413
+ ...fuzzyMatches,
414
+ ];
216
415
  }
217
- export function extractRefs(filePath, content) {
416
+ export function extractRefs(filePath, content, latticeDir) {
218
417
  const tree = parse(stripFrontmatter(content));
219
- const file = basename(filePath, '.md');
418
+ const file = latticeDir
419
+ ? relative(latticeDir, filePath).replace(/\.md$/, '')
420
+ : basename(filePath, '.md');
220
421
  const refs = [];
221
422
  // Build a flat list of sections to determine enclosing section for each wiki link
222
423
  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.4",
3
+ "version": "0.3.0",
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
 
@@ -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`.
@@ -0,0 +1,16 @@
1
+ #!/bin/bash
2
+ # lat.md prompt hook for Claude Code
3
+ # Injected by `lat init` — reminds the agent to consult lat.md before working.
4
+ #
5
+ # This hook fires on every user prompt submission. It adds a short instruction
6
+ # telling the agent to run `lat search` and `lat prompt` so that lat.md context
7
+ # is consulted before any code changes.
8
+
9
+ cat <<'HOOK_JSON'
10
+ {
11
+ "hookSpecificOutput": {
12
+ "hookEventName": "UserPromptSubmit",
13
+ "additionalContext": "Before starting work on this task:\n1. Run `lat search` with a query relevant to the task and read the results to understand the design intent.\n2. If the prompt contains [[refs]], run `lat prompt` on the full prompt text to resolve them.\n3. After completing work, run `lat check` to validate all links and code refs.\nDo not skip these steps."
14
+ }
15
+ }
16
+ HOOK_JSON
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`.