lat.md 0.4.2 → 0.5.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.
@@ -127,19 +127,26 @@ function immediateEntries(walkedPaths) {
127
127
  }
128
128
  return [...entries].sort();
129
129
  }
130
- /** Parse bullet items from an index file. Matches `- **name** — description` */
130
+ /** Parse bullet items from an index file. Matches `- [[name]] — description` */
131
131
  function parseIndexEntries(content) {
132
132
  const names = new Set();
133
- const re = /^- \*\*(.+?)\*\*/gm;
133
+ const re = /^- \[\[([^\]]+?)(?:\|[^\]]+)?\]\]/gm;
134
134
  let match;
135
135
  while ((match = re.exec(content)) !== null) {
136
136
  names.add(match[1]);
137
137
  }
138
138
  return names;
139
139
  }
140
+ /**
141
+ * Convert a filesystem entry name to its wiki link stem.
142
+ * Strips `.md` extension from files; directories stay as-is.
143
+ */
144
+ function entryToStem(name) {
145
+ return name.endsWith('.md') ? name.slice(0, -3) : name;
146
+ }
140
147
  /** Generate a bullet-list snippet for the given entry names. */
141
148
  function indexSnippet(entries) {
142
- return entries.map((e) => `- **${e}** — <describe>`).join('\n');
149
+ return entries.map((e) => `- [[${entryToStem(e)}]] — <describe>`).join('\n');
143
150
  }
144
151
  export async function checkIndex(latticeDir) {
145
152
  const errors = [];
@@ -183,12 +190,16 @@ export async function checkIndex(latticeDir) {
183
190
  });
184
191
  continue;
185
192
  }
186
- // Parse existing entries and validate
193
+ // Parse existing entries and validate.
194
+ // Listed entries are wiki link stems (no .md extension).
195
+ // Children are filesystem names (with .md for files, bare for dirs).
187
196
  const listed = parseIndexEntries(content);
197
+ const childStems = new Set(children.map(entryToStem));
198
+ const stemToChild = new Map(children.map((c) => [entryToStem(c), c]));
188
199
  const relDir = dir === '' ? basename(latticeDir) + '/' : dir + '/';
189
200
  const missing = [];
190
201
  for (const child of children) {
191
- if (!listed.has(child)) {
202
+ if (!listed.has(entryToStem(child))) {
192
203
  missing.push(child);
193
204
  }
194
205
  }
@@ -199,11 +210,12 @@ export async function checkIndex(latticeDir) {
199
210
  snippet: indexSnippet(missing),
200
211
  });
201
212
  }
213
+ const indexStem = entryToStem(indexFileName);
202
214
  for (const name of listed) {
203
- if (!children.includes(name) && name !== indexFileName) {
215
+ if (!childStems.has(name) && name !== indexStem) {
204
216
  errors.push({
205
217
  dir: relDir,
206
- message: `"${indexRelPath}" lists "${name}" but it does not exist`,
218
+ message: `"${indexRelPath}" lists "[[${name}]]" but it does not exist`,
207
219
  });
208
220
  }
209
221
  }
@@ -8,6 +8,7 @@ export function resolveContext(opts) {
8
8
  const latDir = findLatticeDir(opts.dir) ?? '';
9
9
  if (!latDir) {
10
10
  console.error(chalk.red('No lat.md directory found'));
11
+ console.error(chalk.dim('Run `lat init` to create one.'));
11
12
  process.exit(1);
12
13
  }
13
14
  return { latDir, color, chalk };
@@ -81,7 +81,7 @@ export function parseSections(filePath, content, latticeDir) {
81
81
  stack.pop();
82
82
  }
83
83
  const parent = stack.length > 0 ? stack[stack.length - 1] : null;
84
- const id = parent ? `${parent.id}#${heading}` : file;
84
+ const id = parent ? `${parent.id}#${heading}` : `${file}#${heading}`;
85
85
  const section = {
86
86
  id,
87
87
  heading,
@@ -131,28 +131,14 @@ export function parseSections(filePath, content, latticeDir) {
131
131
  }
132
132
  return roots;
133
133
  }
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;
134
+ export async function loadAllSections(latticeDir) {
135
+ const files = await listLatticeFiles(latticeDir);
136
+ const all = [];
137
+ for (const file of files) {
138
+ const content = await readFile(file, 'utf-8');
139
+ all.push(...parseSections(file, content, latticeDir));
142
140
  }
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;
141
+ return all;
156
142
  }
157
143
  export function flattenSections(sections) {
158
144
  const result = [];
@@ -232,17 +218,39 @@ export function resolveRef(target, sectionIds, fileIndex) {
232
218
  const hashIdx = target.indexOf('#');
233
219
  const filePart = hashIdx === -1 ? target : target.slice(0, hashIdx);
234
220
  const rest = hashIdx === -1 ? '' : target.slice(hashIdx);
235
- const candidates = fileIndex.get(filePart) ?? [];
236
- if (candidates.length === 1) {
237
- const expanded = candidates[0] + rest;
221
+ // Try resolving the file part: either it's a full path or a bare stem
222
+ const filePaths = fileIndex.has(filePart)
223
+ ? fileIndex.get(filePart)
224
+ : [filePart];
225
+ if (filePaths.length === 1) {
226
+ const fp = filePaths[0];
227
+ const expanded = fp + rest;
238
228
  if (sectionIds.has(expanded.toLowerCase())) {
239
229
  return { resolved: expanded, ambiguous: null, suggested: null };
240
230
  }
231
+ // Try inserting root headings between file and rest.
232
+ // Handles Obsidian-style file#heading refs where the h1 is implicit.
233
+ const rootHeadings = findRootHeadings(fp, sectionIds);
234
+ for (const h1 of rootHeadings) {
235
+ const withRoot = rest ? `${fp}#${h1}${rest}` : `${fp}#${h1}`;
236
+ if (sectionIds.has(withRoot.toLowerCase())) {
237
+ return { resolved: withRoot, ambiguous: null, suggested: null };
238
+ }
239
+ }
241
240
  }
242
- else if (candidates.length > 1) {
241
+ else if (filePaths.length > 1) {
243
242
  // 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()));
243
+ const all = filePaths.map((c) => c + rest);
244
+ const valid = filePaths.filter((c) => {
245
+ if (sectionIds.has((c + rest).toLowerCase()))
246
+ return true;
247
+ // Also try with root heading insertion
248
+ const rootHeadings = findRootHeadings(c, sectionIds);
249
+ return rootHeadings.some((h1) => {
250
+ const withRoot = rest ? `${c}#${h1}${rest}` : `${c}#${h1}`;
251
+ return sectionIds.has(withRoot.toLowerCase());
252
+ });
253
+ });
246
254
  return {
247
255
  resolved: target,
248
256
  ambiguous: all,
@@ -251,6 +259,20 @@ export function resolveRef(target, sectionIds, fileIndex) {
251
259
  }
252
260
  return { resolved: target, ambiguous: null, suggested: null };
253
261
  }
262
+ /**
263
+ * Find root (h1) headings for a file by scanning sectionIds for entries
264
+ * that have exactly the pattern `file#heading` (no further # segments).
265
+ */
266
+ function findRootHeadings(file, sectionIds) {
267
+ const prefix = file.toLowerCase() + '#';
268
+ const headings = [];
269
+ for (const id of sectionIds) {
270
+ if (id.startsWith(prefix) && !id.includes('#', prefix.length)) {
271
+ headings.push(id.slice(prefix.length));
272
+ }
273
+ }
274
+ return headings;
275
+ }
254
276
  const MAX_DISTANCE_RATIO = 0.4;
255
277
  export function findSections(sections, query) {
256
278
  const flat = flattenSections(sections);
@@ -266,6 +288,16 @@ export function findSections(sections, query) {
266
288
  }));
267
289
  if (exactMatches.length > 0 && isFullPath)
268
290
  return exactMatches;
291
+ // Tier 1a: bare name matches file — return root sections of that file
292
+ if (!isFullPath && exactMatches.length === 0) {
293
+ const fileRoots = flat.filter((s) => s.file.toLowerCase() === q && !s.id.includes('#', s.file.length + 1));
294
+ if (fileRoots.length > 0) {
295
+ return fileRoots.map((s) => ({
296
+ section: s,
297
+ reason: 'exact match',
298
+ }));
299
+ }
300
+ }
269
301
  // Tier 1b: file stem expansion
270
302
  // For bare names: "locate" → matches root section of "tests/locate.md"
271
303
  // For paths with #: "setup#Install" → expands to "guides/setup#Install"
@@ -276,26 +308,53 @@ export function findSections(sections, query) {
276
308
  const hashIdx = normalized.indexOf('#');
277
309
  const filePart = normalized.slice(0, hashIdx);
278
310
  const rest = normalized.slice(hashIdx);
279
- const paths = fileIndex.get(filePart) ?? [];
280
- for (const p of paths) {
311
+ const stemPaths = fileIndex.get(filePart) ?? [];
312
+ // Also try filePart as a direct file path (for root-level files not in index)
313
+ const allPaths = stemPaths.length > 0 ? stemPaths : filePart ? [filePart] : [];
314
+ for (const p of allPaths) {
281
315
  const expanded = (p + rest).toLowerCase();
282
316
  const s = flat.find((s) => s.id.toLowerCase() === expanded && !exact.includes(s));
283
- if (s)
317
+ if (s) {
284
318
  stemMatches.push({
285
319
  section: s,
286
- reason: `file stem expanded: ${filePart} → ${p}`,
320
+ reason: stemPaths.length > 0
321
+ ? `file stem expanded: ${filePart} → ${p}`
322
+ : 'exact match',
287
323
  });
324
+ continue;
325
+ }
326
+ // Try inserting root headings: file#rest → file#h1#rest
327
+ const rootsOfFile = flat.filter((s) => s.file.toLowerCase() === p.toLowerCase() &&
328
+ !s.id.includes('#', s.file.length + 1));
329
+ for (const root of rootsOfFile) {
330
+ const withRoot = (root.id + rest).toLowerCase();
331
+ const match = flat.find((s) => s.id.toLowerCase() === withRoot && !exact.includes(s));
332
+ if (match) {
333
+ stemMatches.push({
334
+ section: match,
335
+ reason: stemPaths.length > 0
336
+ ? `file stem expanded: ${filePart} → ${p}`
337
+ : 'exact match',
338
+ });
339
+ }
340
+ }
288
341
  }
289
342
  if (stemMatches.length > 0)
290
343
  return [...exactMatches, ...stemMatches];
291
344
  }
292
345
  else {
293
- // Bare name: match file root sections via stem index
346
+ // Bare name: match root sections of files via stem index
294
347
  const paths = fileIndex.get(normalized) ?? [];
295
348
  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' });
349
+ for (const s of flat) {
350
+ if (exact.includes(s))
351
+ continue;
352
+ // Root sections have id = "file#heading" (exactly 2 segments)
353
+ if (s.file.toLowerCase() === p.toLowerCase() &&
354
+ !s.id.includes('#', s.file.length + 1)) {
355
+ stemMatches.push({ section: s, reason: 'file stem match' });
356
+ }
357
+ }
299
358
  }
300
359
  }
301
360
  // Tier 2: exact match on trailing segments (subsection name match)
@@ -437,7 +496,7 @@ export function extractRefs(filePath, content, latticeDir) {
437
496
  stack.pop();
438
497
  }
439
498
  const parent = stack.length > 0 ? stack[stack.length - 1] : null;
440
- const id = parent ? `${parent.id}#${heading}` : file;
499
+ const id = parent ? `${parent.id}#${heading}` : `${file}#${heading}`;
441
500
  flat[idx].id = id;
442
501
  stack.push({ id, depth });
443
502
  idx++;
@@ -2,10 +2,6 @@
2
2
  * Walk a directory tree respecting .gitignore rules. Returns relative paths
3
3
  * of all non-ignored files, excluding .git/ and dotfiles (e.g. .gitignore).
4
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
5
  * This is the single entry point for all directory walking in lat.md — both
10
6
  * code-ref scanning and lat.md/ index validation use it so .gitignore rules
11
7
  * are consistently honored.
package/dist/src/walk.js CHANGED
@@ -1,32 +1,16 @@
1
- import { resolve } from 'node:path';
2
1
  // @ts-expect-error -- no type declarations
3
2
  import walk from 'ignore-walk';
4
- const cache = new Map();
5
3
  /**
6
4
  * Walk a directory tree respecting .gitignore rules. Returns relative paths
7
5
  * of all non-ignored files, excluding .git/ and dotfiles (e.g. .gitignore).
8
6
  *
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
7
  * This is the single entry point for all directory walking in lat.md — both
14
8
  * code-ref scanning and lat.md/ index validation use it so .gitignore rules
15
9
  * are consistently honored.
16
10
  */
17
11
  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({
12
+ return walk({
25
13
  path: dir,
26
14
  ignoreFiles: ['.gitignore'],
27
15
  }).then((entries) => entries.filter((e) => !e.startsWith('.git/') && !e.startsWith('.')));
28
- if (!noCache) {
29
- cache.set(resolve(dir), result);
30
- }
31
- return result;
32
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lat.md",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "A knowledge graph for your codebase, written in markdown",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.30.2",