lat.md 0.7.1 → 0.7.2

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.
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
3
3
  import { basename, dirname, extname, join, relative } from 'node:path';
4
4
  import { listLatticeFiles, loadAllSections, extractRefs, flattenSections, parseFrontmatter, parseSections, buildFileIndex, resolveRef, } from '../lattice.js';
5
5
  import { scanCodeRefs } from '../code-refs.js';
6
+ import { SOURCE_EXTENSIONS } from '../source-parser.js';
6
7
  import { walkEntries } from '../walk.js';
7
8
  import { INIT_VERSION, readInitVersion } from '../init-version.js';
8
9
  function filePart(id) {
@@ -32,23 +33,11 @@ function countByExt(paths) {
32
33
  }
33
34
  return stats;
34
35
  }
35
- /** Source file extensions recognized for code wiki links. */
36
- const SOURCE_EXTS = new Set([
37
- '.ts',
38
- '.tsx',
39
- '.js',
40
- '.jsx',
41
- '.py',
42
- '.rs',
43
- '.go',
44
- '.c',
45
- '.h',
46
- ]);
47
36
  function isSourcePath(target) {
48
37
  const hashIdx = target.indexOf('#');
49
38
  const filePart = hashIdx === -1 ? target : target.slice(0, hashIdx);
50
39
  const ext = extname(filePart);
51
- return SOURCE_EXTS.has(ext);
40
+ return SOURCE_EXTENSIONS.has(ext);
52
41
  }
53
42
  /**
54
43
  * Try resolving a wiki link target as a source code reference.
@@ -61,7 +50,7 @@ async function tryResolveSourceRef(target, projectRoot) {
61
50
  const filePart = hashIdx === -1 ? target : target.slice(0, hashIdx);
62
51
  const ext = extname(filePart);
63
52
  if (ext && hashIdx !== -1) {
64
- const supported = [...SOURCE_EXTS].sort().join(', ');
53
+ const supported = [...SOURCE_EXTENSIONS].sort().join(', ');
65
54
  return `broken link [[${target}]] — unsupported file extension "${ext}". Supported: ${supported}`;
66
55
  }
67
56
  return `broken link [[${target}]] — no matching section found`;
@@ -1,10 +1,13 @@
1
- import { dirname } from 'node:path';
1
+ import { execSync } from 'node:child_process';
2
+ import { dirname, extname } from 'node:path';
2
3
  import { findLatticeDir } from '../lattice.js';
3
4
  import { plainStyler } from '../context.js';
4
5
  import { expandPrompt } from './expand.js';
5
6
  import { runSearch } from './search.js';
6
7
  import { getSection, formatSectionOutput } from './section.js';
7
8
  import { getLlmKey } from '../config.js';
9
+ import { checkMd, checkCodeRefs, checkIndex, checkSections } from './check.js';
10
+ import { SOURCE_EXTENSIONS } from '../source-parser.js';
8
11
  function outputPromptSubmit(context) {
9
12
  process.stdout.write(JSON.stringify({
10
13
  hookSpecificOutput: {
@@ -74,7 +77,7 @@ async function handleUserPromptSubmit() {
74
77
  // If we can't parse stdin, still emit the reminder
75
78
  }
76
79
  const parts = [];
77
- parts.push("Before starting work, run `lat search` with one or more queries describing the user's intent.", 'ALWAYS do this, even when the task seems straightforward — search results may reveal critical design details, protocols, or constraints.', 'Use `lat section` to read the full content of relevant matches.', 'Do not read files, write code, or run commands until you have searched.');
80
+ parts.push("Before starting work, run `lat search` with one or more queries describing the user's intent.", 'ALWAYS do this, even when the task seems straightforward — search results may reveal critical design details, protocols, or constraints.', 'Use `lat section` to read the full content of relevant matches.', 'Do not read files, write code, or run commands until you have searched.', '', 'Remember: `lat.md/` must stay in sync with the codebase. If you change code, update the relevant sections in `lat.md/` and run `lat check` before finishing.');
78
81
  const latDir = findLatticeDir();
79
82
  if (latDir && userPrompt) {
80
83
  const ctx = makeHookCtx(latDir);
@@ -106,8 +109,44 @@ async function handleUserPromptSubmit() {
106
109
  }
107
110
  outputPromptSubmit(parts.join('\n'));
108
111
  }
112
+ /** Minimum diff size (in lines) to consider "significant" code change. */
113
+ /** Minimum code change size (lines) before we consider flagging lat.md/ sync. */
114
+ const DIFF_THRESHOLD = 5;
115
+ /** lat.md/ changes below this ratio of code changes trigger a sync reminder. */
116
+ const LATMD_RATIO = 0.05;
117
+ /** Run `git diff --numstat` and return { codeLines, latMdLines }. */
118
+ function analyzeDiff(projectRoot) {
119
+ let output;
120
+ try {
121
+ output = execSync('git diff HEAD --numstat', {
122
+ cwd: projectRoot,
123
+ encoding: 'utf-8',
124
+ });
125
+ }
126
+ catch {
127
+ return { codeLines: 0, latMdLines: 0 };
128
+ }
129
+ let codeLines = 0;
130
+ let latMdLines = 0;
131
+ // Each line: "added\tremoved\tfile" (e.g. "42\t11\tsrc/cli/hook.ts")
132
+ for (const line of output.split('\n')) {
133
+ const parts = line.split('\t');
134
+ if (parts.length < 3)
135
+ continue;
136
+ const added = parseInt(parts[0], 10) || 0;
137
+ const removed = parseInt(parts[1], 10) || 0;
138
+ const file = parts[2];
139
+ const changed = added + removed;
140
+ if (file.startsWith('lat.md/')) {
141
+ latMdLines += changed;
142
+ }
143
+ else if (SOURCE_EXTENSIONS.has(extname(file))) {
144
+ codeLines += changed;
145
+ }
146
+ }
147
+ return { codeLines, latMdLines };
148
+ }
109
149
  async function handleStop() {
110
- // Only emit the reminder if we're in a project with lat.md
111
150
  const latDir = findLatticeDir();
112
151
  if (!latDir)
113
152
  return;
@@ -121,11 +160,52 @@ async function handleStop() {
121
160
  catch {
122
161
  // If we can't parse stdin, treat as first attempt
123
162
  }
124
- // Don't block twiceavoids infinite loop
125
- if (stopHookActive)
163
+ // Always run lat check even on second pass
164
+ const md = await checkMd(latDir);
165
+ const code = await checkCodeRefs(latDir);
166
+ const indexErrors = await checkIndex(latDir);
167
+ const sectionErrors = await checkSections(latDir);
168
+ const totalErrors = md.errors.length +
169
+ code.errors.length +
170
+ indexErrors.length +
171
+ sectionErrors.length;
172
+ const checkFailed = totalErrors > 0;
173
+ // Second pass — warn the user but don't block again
174
+ if (stopHookActive) {
175
+ if (checkFailed) {
176
+ console.error(`lat check is still failing (${totalErrors} error(s)). Run \`lat check\` to see details.`);
177
+ }
178
+ return;
179
+ }
180
+ const projectRoot = dirname(latDir);
181
+ // Analyze git diff — flag when lat.md/ changes are < 5% of code changes
182
+ const { codeLines, latMdLines } = analyzeDiff(projectRoot);
183
+ let needsSync = false;
184
+ if (codeLines >= DIFF_THRESHOLD) {
185
+ // Round up lat.md lines to 1 if there are more than 5 code lines changed
186
+ // (a tiny lat.md touch still counts as effort)
187
+ const effectiveLatMd = latMdLines === 0 ? 0 : Math.max(latMdLines, 1);
188
+ needsSync = effectiveLatMd < codeLines * LATMD_RATIO;
189
+ }
190
+ // Nothing to do — let the agent stop cleanly
191
+ if (!checkFailed && !needsSync)
126
192
  return;
127
193
  const parts = [];
128
- parts.push('Before finishing, verify:', '- Did you update `lat.md/`? Run `lat search` with a query describing what you changed to find relevant sections that may need updating.', '- Did you run `lat check` and confirm all links and code refs pass?', 'If you made code changes but did not update lat.md/, do that now.');
194
+ if (checkFailed && needsSync) {
195
+ parts.push('`lat check` found errors AND the codebase has changes (' +
196
+ codeLines +
197
+ ' lines) with no updates to `lat.md/`. Before finishing:', '', '1. Update `lat.md/` to reflect your code changes — run `lat search` to find relevant sections.', '2. Run `lat check` until it passes.');
198
+ }
199
+ else if (checkFailed) {
200
+ parts.push('`lat check` found ' +
201
+ totalErrors +
202
+ ' error(s). Run `lat check`, fix the errors, and repeat until it passes.');
203
+ }
204
+ else {
205
+ parts.push('The codebase has changes (' +
206
+ codeLines +
207
+ ' lines) but `lat.md/` was not updated. Update `lat.md/` to be in sync with the changes — run `lat search` to find relevant sections. Run `lat check` at the end.');
208
+ }
129
209
  outputStop(parts.join('\n'));
130
210
  }
131
211
  export async function hookCmd(agent, event) {
@@ -22,13 +22,12 @@ export async function getSection(ctx, query) {
22
22
  return { kind: 'no-match', suggestions: matches };
23
23
  }
24
24
  const section = top.section;
25
- // Read raw content between startLine and endLine
25
+ // Read raw content between startLine and the end of the last descendant
26
26
  const absPath = join(ctx.projectRoot, section.filePath);
27
27
  const fileContent = await readFile(absPath, 'utf-8');
28
28
  const lines = fileContent.split('\n');
29
- const content = lines
30
- .slice(section.startLine - 1, section.endLine)
31
- .join('\n');
29
+ const end = fullEndLine(section);
30
+ const content = lines.slice(section.startLine - 1, end).join('\n');
32
31
  // Find outgoing wiki link targets within this section's content
33
32
  const flat = flattenSections(allSections);
34
33
  const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
@@ -73,6 +72,11 @@ export async function getSection(ctx, query) {
73
72
  }
74
73
  return { kind: 'found', section, content, outgoingRefs, incomingRefs };
75
74
  }
75
+ function fullEndLine(section) {
76
+ if (section.children.length === 0)
77
+ return section.endLine;
78
+ return fullEndLine(section.children[section.children.length - 1]);
79
+ }
76
80
  function truncate(s, max) {
77
81
  return s.length > max ? s.slice(0, max) + '...' : s;
78
82
  }
@@ -7,6 +7,8 @@ export type SourceSymbol = {
7
7
  endLine: number;
8
8
  signature: string;
9
9
  };
10
+ /** All source file extensions that lat can parse (derived from grammarMap). */
11
+ export declare const SOURCE_EXTENSIONS: ReadonlySet<string>;
10
12
  export declare function parseSourceSymbols(filePath: string, content: string): Promise<SourceSymbol[]>;
11
13
  /**
12
14
  * Check whether a source file path (relative to projectRoot) has a given symbol.
@@ -21,18 +21,22 @@ async function ensureParser() {
21
21
  }
22
22
  return parserInstance;
23
23
  }
24
+ /** Extension → tree-sitter WASM grammar mapping. This is the single source of
25
+ * truth for which source file extensions lat supports. */
26
+ const grammarMap = {
27
+ '.ts': 'tree-sitter-typescript.wasm',
28
+ '.tsx': 'tree-sitter-tsx.wasm',
29
+ '.js': 'tree-sitter-javascript.wasm',
30
+ '.jsx': 'tree-sitter-javascript.wasm',
31
+ '.py': 'tree-sitter-python.wasm',
32
+ '.rs': 'tree-sitter-rust.wasm',
33
+ '.go': 'tree-sitter-go.wasm',
34
+ '.c': 'tree-sitter-c.wasm',
35
+ '.h': 'tree-sitter-c.wasm',
36
+ };
37
+ /** All source file extensions that lat can parse (derived from grammarMap). */
38
+ export const SOURCE_EXTENSIONS = new Set(Object.keys(grammarMap));
24
39
  async function getLanguage(ext) {
25
- const grammarMap = {
26
- '.ts': 'tree-sitter-typescript.wasm',
27
- '.tsx': 'tree-sitter-tsx.wasm',
28
- '.js': 'tree-sitter-javascript.wasm',
29
- '.jsx': 'tree-sitter-javascript.wasm',
30
- '.py': 'tree-sitter-python.wasm',
31
- '.rs': 'tree-sitter-rust.wasm',
32
- '.go': 'tree-sitter-go.wasm',
33
- '.c': 'tree-sitter-c.wasm',
34
- '.h': 'tree-sitter-c.wasm',
35
- };
36
40
  const wasmFile = grammarMap[ext];
37
41
  if (!wasmFile)
38
42
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lat.md",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "A knowledge graph for your codebase, written in markdown",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.30.2",