lat.md 0.8.2 → 0.10.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 +9 -10
- package/dist/src/cli/check.js +20 -3
- package/dist/src/cli/gen.d.ts +1 -0
- package/dist/src/cli/gen.js +7 -1
- package/dist/src/cli/index.js +1 -1
- package/dist/src/cli/init.d.ts +1 -0
- package/dist/src/cli/init.js +47 -4
- package/dist/src/cli/refs.d.ts +4 -2
- package/dist/src/cli/refs.js +126 -3
- package/dist/src/cli/section.d.ts +13 -0
- package/dist/src/cli/section.js +103 -4
- package/dist/src/cli/select-menu.js +1 -1
- package/dist/src/code-refs.d.ts +3 -0
- package/dist/src/code-refs.js +161 -4
- package/dist/src/mcp/server.js +1 -1
- package/package.json +1 -1
- package/templates/logo.txt +7 -0
- package/templates/pi-extension.ts +9 -6
- package/templates/skill/SKILL.md +169 -0
package/README.md
CHANGED
|
@@ -27,9 +27,7 @@ The result is a structured knowledge base that:
|
|
|
27
27
|
npm install -g lat.md
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
After installing, run `lat init` in the repo you want to use lat in.
|
|
30
|
+
Then run `lat init` in the repo you want to use lat in.
|
|
33
31
|
|
|
34
32
|
## How it works
|
|
35
33
|
|
|
@@ -50,13 +48,14 @@ my-project/
|
|
|
50
48
|
## CLI
|
|
51
49
|
|
|
52
50
|
```bash
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
51
|
+
lat init # scaffold a lat.md/ directory
|
|
52
|
+
lat check # validate all wiki links and code refs
|
|
53
|
+
lat locate "OAuth Flow" # find sections by name (exact, fuzzy)
|
|
54
|
+
lat section "auth#OAuth Flow" # show a section with its links and refs
|
|
55
|
+
lat refs "auth#OAuth Flow" # find what references a section
|
|
56
|
+
lat search "how do we auth?" # semantic search via embeddings
|
|
57
|
+
lat expand "fix [[OAuth Flow]]" # expand [[refs]] in a prompt for agents
|
|
58
|
+
lat mcp # start MCP server for editor integration
|
|
60
59
|
```
|
|
61
60
|
|
|
62
61
|
## Configuration
|
package/dist/src/cli/check.js
CHANGED
|
@@ -131,9 +131,10 @@ export async function checkCodeRefs(latticeDir) {
|
|
|
131
131
|
for (const ref of scan.refs) {
|
|
132
132
|
const { resolved, ambiguous, suggested } = resolveRef(ref.target, sectionIds, fileIndex);
|
|
133
133
|
mentionedSections.add(resolved.toLowerCase());
|
|
134
|
+
const displayPath = relative(process.cwd(), join(projectRoot, ref.file));
|
|
134
135
|
if (ambiguous) {
|
|
135
136
|
errors.push({
|
|
136
|
-
file:
|
|
137
|
+
file: displayPath,
|
|
137
138
|
line: ref.line,
|
|
138
139
|
target: ref.target,
|
|
139
140
|
message: ambiguousMessage(ref.target, ambiguous, suggested),
|
|
@@ -141,7 +142,7 @@ export async function checkCodeRefs(latticeDir) {
|
|
|
141
142
|
}
|
|
142
143
|
else if (!sectionIds.has(resolved.toLowerCase())) {
|
|
143
144
|
errors.push({
|
|
144
|
-
file:
|
|
145
|
+
file: displayPath,
|
|
145
146
|
line: ref.line,
|
|
146
147
|
target: ref.target,
|
|
147
148
|
message: `@lat: [[${ref.target}]] — no matching section found`,
|
|
@@ -371,17 +372,22 @@ function formatErrorCount(count, s) {
|
|
|
371
372
|
}
|
|
372
373
|
// --- Unified command functions ---
|
|
373
374
|
export async function checkAllCommand(ctx) {
|
|
375
|
+
const startTime = Date.now();
|
|
374
376
|
const md = await checkMd(ctx.latDir);
|
|
375
377
|
const code = await checkCodeRefs(ctx.latDir);
|
|
376
378
|
const indexErrors = await checkIndex(ctx.latDir);
|
|
377
379
|
const sectionErrors = await checkSections(ctx.latDir);
|
|
380
|
+
const elapsed = Date.now() - startTime;
|
|
378
381
|
const allErrors = [...md.errors, ...code.errors];
|
|
379
382
|
const allFiles = { ...md.files };
|
|
380
383
|
for (const [ext, n] of Object.entries(code.files)) {
|
|
381
384
|
allFiles[ext] = (allFiles[ext] || 0) + n;
|
|
382
385
|
}
|
|
383
386
|
const s = ctx.styler;
|
|
384
|
-
const
|
|
387
|
+
const elapsedStr = elapsed < 1000 ? `${elapsed}ms` : `${(elapsed / 1000).toFixed(1)}s`;
|
|
388
|
+
const lines = [
|
|
389
|
+
formatFileStats(allFiles, s) + s.dim(` in ${elapsedStr}`),
|
|
390
|
+
];
|
|
385
391
|
// Init version warning first — user should fix setup before addressing errors
|
|
386
392
|
const storedVersion = readInitVersion(ctx.latDir);
|
|
387
393
|
if (storedVersion === null) {
|
|
@@ -424,6 +430,17 @@ export async function checkAllCommand(ctx) {
|
|
|
424
430
|
s.cyan('lat init') +
|
|
425
431
|
' to configure.');
|
|
426
432
|
}
|
|
433
|
+
// Suggest ripgrep if check was slow (>1s) and rg is not available
|
|
434
|
+
if (elapsed > 1000) {
|
|
435
|
+
const { hasRipgrep } = await import('../code-refs.js');
|
|
436
|
+
if (!(await hasRipgrep())) {
|
|
437
|
+
lines.push(s.yellow('Tip:') +
|
|
438
|
+
' Install ' +
|
|
439
|
+
s.cyan('ripgrep') +
|
|
440
|
+
' (rg) for faster code scanning.' +
|
|
441
|
+
' See https://github.com/BurntSushi/ripgrep#installation');
|
|
442
|
+
}
|
|
443
|
+
}
|
|
427
444
|
return { output: lines.join('\n') };
|
|
428
445
|
}
|
|
429
446
|
export async function checkMdCommand(ctx) {
|
package/dist/src/cli/gen.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export declare function readAgentsTemplate(): string;
|
|
2
2
|
export declare function readCursorRulesTemplate(): string;
|
|
3
3
|
export declare function readPiExtensionTemplate(): string;
|
|
4
|
+
export declare function readSkillTemplate(): string;
|
|
4
5
|
export declare function genCmd(target: string): Promise<void>;
|
package/dist/src/cli/gen.js
CHANGED
|
@@ -10,6 +10,9 @@ export function readCursorRulesTemplate() {
|
|
|
10
10
|
export function readPiExtensionTemplate() {
|
|
11
11
|
return readFileSync(join(findTemplatesDir(), 'pi-extension.ts'), 'utf-8');
|
|
12
12
|
}
|
|
13
|
+
export function readSkillTemplate() {
|
|
14
|
+
return readFileSync(join(findTemplatesDir(), 'skill', 'SKILL.md'), 'utf-8');
|
|
15
|
+
}
|
|
13
16
|
export async function genCmd(target) {
|
|
14
17
|
const normalized = target.toLowerCase();
|
|
15
18
|
switch (normalized) {
|
|
@@ -23,8 +26,11 @@ export async function genCmd(target) {
|
|
|
23
26
|
case 'pi-extension.ts':
|
|
24
27
|
process.stdout.write(readPiExtensionTemplate());
|
|
25
28
|
break;
|
|
29
|
+
case 'skill.md':
|
|
30
|
+
process.stdout.write(readSkillTemplate());
|
|
31
|
+
break;
|
|
26
32
|
default:
|
|
27
|
-
console.error(`Unknown target: ${target}. Supported: agents.md, claude.md, cursor-rules.md, pi-extension.ts`);
|
|
33
|
+
console.error(`Unknown target: ${target}. Supported: agents.md, claude.md, cursor-rules.md, pi-extension.ts, skill.md`);
|
|
28
34
|
process.exit(1);
|
|
29
35
|
}
|
|
30
36
|
}
|
package/dist/src/cli/index.js
CHANGED
|
@@ -61,7 +61,7 @@ program
|
|
|
61
61
|
.command('refs')
|
|
62
62
|
.description('Find references to a section')
|
|
63
63
|
.argument('<query>', 'section id to find references for')
|
|
64
|
-
.option('--scope <scope>', 'where to search: md, code, or md+code', 'md')
|
|
64
|
+
.option('--scope <scope>', 'where to search: md, code, or md+code', 'md+code')
|
|
65
65
|
.action(async (query, opts) => {
|
|
66
66
|
const scope = opts.scope;
|
|
67
67
|
if (scope !== 'md' && scope !== 'code' && scope !== 'md+code') {
|
package/dist/src/cli/init.d.ts
CHANGED
package/dist/src/cli/init.js
CHANGED
|
@@ -4,7 +4,7 @@ import { execSync } from 'node:child_process';
|
|
|
4
4
|
import { createInterface } from 'node:readline/promises';
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
import { findTemplatesDir } from './templates.js';
|
|
7
|
-
import { readAgentsTemplate, readCursorRulesTemplate, readPiExtensionTemplate, } from './gen.js';
|
|
7
|
+
import { readAgentsTemplate, readCursorRulesTemplate, readPiExtensionTemplate, readSkillTemplate, } from './gen.js';
|
|
8
8
|
import { getLlmKey, getConfigPath, readConfig, writeConfig, } from '../config.js';
|
|
9
9
|
import { writeInitMeta, readFileHash, contentHash } from '../init-version.js';
|
|
10
10
|
import { getLocalVersion, fetchLatestVersion } from '../version.js';
|
|
@@ -314,6 +314,15 @@ async function writeTemplateFile(root, latDir, relPath, template, genTarget, lab
|
|
|
314
314
|
' to see the latest template.');
|
|
315
315
|
return null;
|
|
316
316
|
}
|
|
317
|
+
// ── Shared skill setup ───────────────────────────────────────────────
|
|
318
|
+
async function writeAgentsSkill(root, latDir, hashes, ask) {
|
|
319
|
+
console.log('');
|
|
320
|
+
console.log(chalk.dim(' The lat-md skill teaches the agent how to write and maintain lat.md/ files.'));
|
|
321
|
+
const skillTemplate = readSkillTemplate();
|
|
322
|
+
const skillHash = await writeTemplateFile(root, latDir, '.agents/skills/lat-md/SKILL.md', skillTemplate, 'skill.md', 'Skill (.agents/skills/lat-md/SKILL.md)', ' ', ask);
|
|
323
|
+
if (skillHash)
|
|
324
|
+
hashes['.agents/skills/lat-md/SKILL.md'] = skillHash;
|
|
325
|
+
}
|
|
317
326
|
// ── Per-agent setup ──────────────────────────────────────────────────
|
|
318
327
|
async function setupAgentsMd(root, latDir, template, hashes, ask) {
|
|
319
328
|
const hash = await writeTemplateFile(root, latDir, 'AGENTS.md', template, 'agents.md', 'AGENTS.md', '', ask);
|
|
@@ -334,6 +343,13 @@ async function setupClaudeCode(root, latDir, template, hashes, ask, style) {
|
|
|
334
343
|
mkdirSync(claudeDir, { recursive: true });
|
|
335
344
|
syncLatHooks(settingsPath, style);
|
|
336
345
|
console.log(chalk.green(' Hooks') + ' synced (UserPromptSubmit + Stop)');
|
|
346
|
+
// .claude/skills/lat-md/SKILL.md — skill for authoring lat.md files
|
|
347
|
+
console.log('');
|
|
348
|
+
console.log(chalk.dim(' The lat-md skill teaches the agent how to write and maintain lat.md/ files.'));
|
|
349
|
+
const skillTemplate = readSkillTemplate();
|
|
350
|
+
const skillHash = await writeTemplateFile(root, latDir, '.claude/skills/lat-md/SKILL.md', skillTemplate, 'skill.md', 'Skill (.claude/skills/lat-md/SKILL.md)', ' ', ask);
|
|
351
|
+
if (skillHash)
|
|
352
|
+
hashes['.claude/skills/lat-md/SKILL.md'] = skillHash;
|
|
337
353
|
// Ensure .claude is gitignored (settings contain local absolute paths)
|
|
338
354
|
ensureGitignored(root, '.claude');
|
|
339
355
|
// MCP server → .mcp.json at project root
|
|
@@ -370,6 +386,8 @@ async function setupCursor(root, latDir, hashes, ask, style) {
|
|
|
370
386
|
}
|
|
371
387
|
// Ensure .cursor/mcp.json is gitignored (it contains local absolute paths)
|
|
372
388
|
ensureGitignored(root, '.cursor/mcp.json');
|
|
389
|
+
// .agents/skills/lat-md/SKILL.md — skill for authoring lat.md files
|
|
390
|
+
await writeAgentsSkill(root, latDir, hashes, ask);
|
|
373
391
|
console.log('');
|
|
374
392
|
console.log(chalk.yellow(' Note:') +
|
|
375
393
|
' Enable MCP in Cursor: Settings → Features → MCP → check "Enable MCP"');
|
|
@@ -391,6 +409,8 @@ async function setupCopilot(root, latDir, hashes, ask, style) {
|
|
|
391
409
|
addMcpServer(mcpPath, 'servers', style);
|
|
392
410
|
console.log(chalk.green(' MCP server') + ' registered in .vscode/mcp.json');
|
|
393
411
|
}
|
|
412
|
+
// .agents/skills/lat-md/SKILL.md — skill for authoring lat.md files
|
|
413
|
+
await writeAgentsSkill(root, latDir, hashes, ask);
|
|
394
414
|
}
|
|
395
415
|
async function setupPi(root, latDir, hashes, ask, style) {
|
|
396
416
|
// AGENTS.md — Pi reads this natively
|
|
@@ -403,6 +423,13 @@ async function setupPi(root, latDir, hashes, ask, style) {
|
|
|
403
423
|
const hash = await writeTemplateFile(root, latDir, '.pi/extensions/lat.ts', template, 'pi-extension.ts', 'Extension (.pi/extensions/lat.ts)', ' ', ask);
|
|
404
424
|
if (hash)
|
|
405
425
|
hashes['.pi/extensions/lat.ts'] = hash;
|
|
426
|
+
// .pi/skills/lat-md/SKILL.md — skill for authoring lat.md files
|
|
427
|
+
console.log('');
|
|
428
|
+
console.log(chalk.dim(' The lat-md skill teaches the agent how to write and maintain lat.md/ files.'));
|
|
429
|
+
const skillTemplate = readSkillTemplate();
|
|
430
|
+
const skillHash = await writeTemplateFile(root, latDir, '.pi/skills/lat-md/SKILL.md', skillTemplate, 'skill.md', 'Skill (.pi/skills/lat-md/SKILL.md)', ' ', ask);
|
|
431
|
+
if (skillHash)
|
|
432
|
+
hashes['.pi/skills/lat-md/SKILL.md'] = skillHash;
|
|
406
433
|
// Ensure .pi is gitignored (extension contains local absolute paths)
|
|
407
434
|
ensureGitignored(root, '.pi');
|
|
408
435
|
}
|
|
@@ -477,7 +504,11 @@ async function setupLlmKey(rl) {
|
|
|
477
504
|
console.log(chalk.green(' Key saved') + ' to ' + chalk.dim(getConfigPath()));
|
|
478
505
|
}
|
|
479
506
|
// ── Main init flow ───────────────────────────────────────────────────
|
|
507
|
+
export function readLogo() {
|
|
508
|
+
return readFileSync(join(findTemplatesDir(), 'logo.txt'), 'utf-8');
|
|
509
|
+
}
|
|
480
510
|
export async function initCmd(targetDir) {
|
|
511
|
+
console.log(chalk.cyan(readLogo()));
|
|
481
512
|
// Upfront version check — let the user upgrade before proceeding
|
|
482
513
|
process.stdout.write(chalk.dim('Checking latest version...'));
|
|
483
514
|
const latest = await fetchLatestVersion();
|
|
@@ -554,7 +585,7 @@ export async function initCmd(targetDir) {
|
|
|
554
585
|
{
|
|
555
586
|
label: selectedAgents.length === 0
|
|
556
587
|
? "I don't use any of these"
|
|
557
|
-
: 'This is it:
|
|
588
|
+
: 'This is it: continue',
|
|
558
589
|
value: '__done__',
|
|
559
590
|
accent: true,
|
|
560
591
|
},
|
|
@@ -638,8 +669,9 @@ export async function initCmd(targetDir) {
|
|
|
638
669
|
}
|
|
639
670
|
if (useCodex) {
|
|
640
671
|
console.log('');
|
|
641
|
-
console.log(chalk.bold('Codex / OpenCode')
|
|
642
|
-
|
|
672
|
+
console.log(chalk.bold('Setting up Codex / OpenCode...'));
|
|
673
|
+
console.log(chalk.dim(' Uses AGENTS.md (already created).'));
|
|
674
|
+
await writeAgentsSkill(root, latDir, fileHashes, ask);
|
|
643
675
|
}
|
|
644
676
|
// Step 5: LLM key setup
|
|
645
677
|
await setupLlmKey(rl);
|
|
@@ -650,6 +682,17 @@ export async function initCmd(targetDir) {
|
|
|
650
682
|
' Run ' +
|
|
651
683
|
chalk.cyan('lat check') +
|
|
652
684
|
' to validate your setup.');
|
|
685
|
+
// Suggest ripgrep if not available
|
|
686
|
+
const { hasRipgrep } = await import('../code-refs.js');
|
|
687
|
+
if (!(await hasRipgrep())) {
|
|
688
|
+
console.log('');
|
|
689
|
+
console.log(chalk.yellow('Tip:') +
|
|
690
|
+
' Install ' +
|
|
691
|
+
chalk.cyan('ripgrep') +
|
|
692
|
+
' (rg) for faster code scanning.' +
|
|
693
|
+
' See ' +
|
|
694
|
+
chalk.underline('https://github.com/BurntSushi/ripgrep#installation'));
|
|
695
|
+
}
|
|
653
696
|
}
|
|
654
697
|
finally {
|
|
655
698
|
rl?.close();
|
package/dist/src/cli/refs.d.ts
CHANGED
|
@@ -13,8 +13,10 @@ export type RefsError = {
|
|
|
13
13
|
};
|
|
14
14
|
export type RefsResult = RefsFound | RefsError;
|
|
15
15
|
/**
|
|
16
|
-
* Find all sections and code locations that reference a given section
|
|
17
|
-
*
|
|
16
|
+
* Find all sections and code locations that reference a given section or
|
|
17
|
+
* source file. Accepts section ids (full-path, short-form) and source file
|
|
18
|
+
* paths (e.g. src/app.rs#foo). Source file queries match wiki links directly
|
|
19
|
+
* without section resolution.
|
|
18
20
|
*/
|
|
19
21
|
export declare function findRefs(ctx: CmdContext, query: string, scope: Scope): Promise<RefsResult>;
|
|
20
22
|
export declare function refsCommand(ctx: CmdContext, query: string, scope: Scope): Promise<CmdResult>;
|
package/dist/src/cli/refs.js
CHANGED
|
@@ -1,13 +1,135 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { extname, join, relative } from 'node:path';
|
|
2
4
|
import { listLatticeFiles, loadAllSections, findSections, extractRefs, flattenSections, buildFileIndex, resolveRef, } from '../lattice.js';
|
|
3
5
|
import { formatResultList } from '../format.js';
|
|
4
6
|
import { scanCodeRefs } from '../code-refs.js';
|
|
7
|
+
/** Extensions recognized as source code for ref queries. */
|
|
8
|
+
const SOURCE_EXTS = new Set([
|
|
9
|
+
'.ts',
|
|
10
|
+
'.tsx',
|
|
11
|
+
'.js',
|
|
12
|
+
'.jsx',
|
|
13
|
+
'.py',
|
|
14
|
+
'.rs',
|
|
15
|
+
'.go',
|
|
16
|
+
'.c',
|
|
17
|
+
'.h',
|
|
18
|
+
]);
|
|
5
19
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
20
|
+
* Check if a query looks like a source file path (has a recognized extension
|
|
21
|
+
* and the file exists on disk).
|
|
22
|
+
*/
|
|
23
|
+
function isSourceQuery(query, projectRoot) {
|
|
24
|
+
const hashIdx = query.indexOf('#');
|
|
25
|
+
const filePart = hashIdx === -1 ? query : query.slice(0, hashIdx);
|
|
26
|
+
const symbolPart = hashIdx === -1 ? '' : query.slice(hashIdx + 1);
|
|
27
|
+
const ext = extname(filePart);
|
|
28
|
+
if (!SOURCE_EXTS.has(ext))
|
|
29
|
+
return null;
|
|
30
|
+
if (!existsSync(join(projectRoot, filePart)))
|
|
31
|
+
return null;
|
|
32
|
+
return { filePart, symbolPart };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Find references to a source file or symbol across lat.md and code files.
|
|
36
|
+
* For file-level queries (no #symbol), matches all wiki links targeting
|
|
37
|
+
* that file or any symbol in it.
|
|
38
|
+
*/
|
|
39
|
+
async function findSourceRefs(latDir, projectRoot, query, scope) {
|
|
40
|
+
const hashIdx = query.indexOf('#');
|
|
41
|
+
const filePart = hashIdx === -1 ? query : query.slice(0, hashIdx);
|
|
42
|
+
const isFileLevel = hashIdx === -1;
|
|
43
|
+
const queryLower = query.toLowerCase();
|
|
44
|
+
const fileLower = filePart.toLowerCase();
|
|
45
|
+
// Build a synthetic Section for the target
|
|
46
|
+
const target = {
|
|
47
|
+
id: query,
|
|
48
|
+
heading: hashIdx === -1 ? filePart : query.slice(hashIdx + 1),
|
|
49
|
+
depth: 0,
|
|
50
|
+
file: filePart,
|
|
51
|
+
filePath: filePart,
|
|
52
|
+
children: [],
|
|
53
|
+
startLine: 0,
|
|
54
|
+
endLine: 0,
|
|
55
|
+
firstParagraph: '',
|
|
56
|
+
};
|
|
57
|
+
// Try to get real line info from the source parser
|
|
58
|
+
try {
|
|
59
|
+
const { resolveSourceSymbol } = await import('../source-parser.js');
|
|
60
|
+
if (hashIdx !== -1) {
|
|
61
|
+
const symbolPart = query.slice(hashIdx + 1);
|
|
62
|
+
const { found, symbols } = await resolveSourceSymbol(filePart, symbolPart, projectRoot);
|
|
63
|
+
if (found) {
|
|
64
|
+
const parts = symbolPart.split('#');
|
|
65
|
+
const sym = symbols.find((s) => parts.length === 1
|
|
66
|
+
? s.name === parts[0] && !s.parent
|
|
67
|
+
: s.name === parts[1] && s.parent === parts[0]);
|
|
68
|
+
if (sym) {
|
|
69
|
+
target.startLine = sym.startLine;
|
|
70
|
+
target.endLine = sym.endLine;
|
|
71
|
+
target.firstParagraph = sym.signature;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// source parser unavailable — proceed without line info
|
|
78
|
+
}
|
|
79
|
+
const allSections = await loadAllSections(latDir);
|
|
80
|
+
const flat = flattenSections(allSections);
|
|
81
|
+
const mdRefs = [];
|
|
82
|
+
const codeRefs = [];
|
|
83
|
+
if (scope === 'md' || scope === 'md+code') {
|
|
84
|
+
const files = await listLatticeFiles(latDir);
|
|
85
|
+
const matchingFromSections = new Set();
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
const content = await readFile(file, 'utf-8');
|
|
88
|
+
const fileRefs = extractRefs(file, content, projectRoot);
|
|
89
|
+
for (const ref of fileRefs) {
|
|
90
|
+
const targetLower = ref.target.toLowerCase();
|
|
91
|
+
const matches = isFileLevel
|
|
92
|
+
? targetLower === fileLower || targetLower.startsWith(fileLower + '#')
|
|
93
|
+
: targetLower === queryLower;
|
|
94
|
+
if (matches) {
|
|
95
|
+
matchingFromSections.add(ref.fromSection.toLowerCase());
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (matchingFromSections.size > 0) {
|
|
100
|
+
const referrers = flat.filter((s) => matchingFromSections.has(s.id.toLowerCase()));
|
|
101
|
+
for (const s of referrers) {
|
|
102
|
+
mdRefs.push({ section: s, reason: 'wiki link' });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (scope === 'code' || scope === 'md+code') {
|
|
107
|
+
const { refs: scannedRefs } = await scanCodeRefs(projectRoot);
|
|
108
|
+
for (const ref of scannedRefs) {
|
|
109
|
+
const targetLower = ref.target.toLowerCase();
|
|
110
|
+
const matches = isFileLevel
|
|
111
|
+
? targetLower === fileLower || targetLower.startsWith(fileLower + '#')
|
|
112
|
+
: targetLower === queryLower;
|
|
113
|
+
if (matches) {
|
|
114
|
+
const displayPath = relative(process.cwd(), join(projectRoot, ref.file));
|
|
115
|
+
codeRefs.push(`${displayPath}:${ref.line}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { kind: 'found', target, mdRefs, codeRefs };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Find all sections and code locations that reference a given section or
|
|
123
|
+
* source file. Accepts section ids (full-path, short-form) and source file
|
|
124
|
+
* paths (e.g. src/app.rs#foo). Source file queries match wiki links directly
|
|
125
|
+
* without section resolution.
|
|
8
126
|
*/
|
|
9
127
|
export async function findRefs(ctx, query, scope) {
|
|
10
128
|
query = query.replace(/^\[\[|\]\]$/g, '');
|
|
129
|
+
// Source file queries bypass section resolution
|
|
130
|
+
if (isSourceQuery(query, ctx.projectRoot)) {
|
|
131
|
+
return findSourceRefs(ctx.latDir, ctx.projectRoot, query, scope);
|
|
132
|
+
}
|
|
11
133
|
const allSections = await loadAllSections(ctx.latDir);
|
|
12
134
|
const flat = flattenSections(allSections);
|
|
13
135
|
const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
|
|
@@ -58,7 +180,8 @@ export async function findRefs(ctx, query, scope) {
|
|
|
58
180
|
for (const ref of scannedRefs) {
|
|
59
181
|
const { resolved: codeResolved } = resolveRef(ref.target, sectionIds, fileIndex);
|
|
60
182
|
if (codeResolved.toLowerCase() === targetId) {
|
|
61
|
-
|
|
183
|
+
const displayPath = relative(process.cwd(), join(ctx.projectRoot, ref.file));
|
|
184
|
+
codeRefs.push(`${displayPath}:${ref.line}`);
|
|
62
185
|
}
|
|
63
186
|
}
|
|
64
187
|
}
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { type Section, type SectionMatch } from '../lattice.js';
|
|
2
2
|
import type { CmdContext, CmdResult } from '../context.js';
|
|
3
|
+
export type CodeBackRef = {
|
|
4
|
+
file: string;
|
|
5
|
+
line: number;
|
|
6
|
+
snippet: string;
|
|
7
|
+
};
|
|
8
|
+
export type SourceRef = {
|
|
9
|
+
target: string;
|
|
10
|
+
file: string;
|
|
11
|
+
line: number;
|
|
12
|
+
snippet: string;
|
|
13
|
+
};
|
|
3
14
|
export type SectionFound = {
|
|
4
15
|
kind: 'found';
|
|
5
16
|
section: Section;
|
|
@@ -8,7 +19,9 @@ export type SectionFound = {
|
|
|
8
19
|
target: string;
|
|
9
20
|
resolved: Section;
|
|
10
21
|
}[];
|
|
22
|
+
outgoingSourceRefs: SourceRef[];
|
|
11
23
|
incomingRefs: SectionMatch[];
|
|
24
|
+
codeRefs: CodeBackRef[];
|
|
12
25
|
};
|
|
13
26
|
export type SectionResult = SectionFound | {
|
|
14
27
|
kind: 'no-match';
|
package/dist/src/cli/section.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { join, relative } from 'node:path';
|
|
2
|
+
import { extname, join, relative } from 'node:path';
|
|
3
3
|
import { loadAllSections, findSections, flattenSections, extractRefs, buildFileIndex, resolveRef, listLatticeFiles, } from '../lattice.js';
|
|
4
|
+
import { scanCodeRefs } from '../code-refs.js';
|
|
5
|
+
import { SOURCE_EXTENSIONS, resolveSourceSymbol } from '../source-parser.js';
|
|
4
6
|
import { formatSectionId, formatNavHints } from '../format.js';
|
|
5
7
|
/**
|
|
6
8
|
* Look up a section by id, return its content, outgoing wiki link targets,
|
|
@@ -35,10 +37,53 @@ export async function getSection(ctx, query) {
|
|
|
35
37
|
const sectionRefs = extractRefs(absPath, fileContent, ctx.projectRoot);
|
|
36
38
|
const sectionId = section.id.toLowerCase();
|
|
37
39
|
const outgoingRefs = [];
|
|
40
|
+
const outgoingSourceRefs = [];
|
|
38
41
|
const seen = new Set();
|
|
39
42
|
for (const ref of sectionRefs) {
|
|
40
43
|
if (ref.fromSection.toLowerCase() !== sectionId)
|
|
41
44
|
continue;
|
|
45
|
+
// Detect source code references by file extension
|
|
46
|
+
const hashIdx = ref.target.indexOf('#');
|
|
47
|
+
const filePart = hashIdx === -1 ? ref.target : ref.target.slice(0, hashIdx);
|
|
48
|
+
const ext = extname(filePart);
|
|
49
|
+
if (SOURCE_EXTENSIONS.has(ext)) {
|
|
50
|
+
const targetLower = ref.target.toLowerCase();
|
|
51
|
+
if (!seen.has(targetLower)) {
|
|
52
|
+
seen.add(targetLower);
|
|
53
|
+
const symbolPart = hashIdx === -1 ? '' : ref.target.slice(hashIdx + 1);
|
|
54
|
+
let line = 0;
|
|
55
|
+
let snippet = '';
|
|
56
|
+
if (symbolPart) {
|
|
57
|
+
const { found, symbols } = await resolveSourceSymbol(filePart, symbolPart, ctx.projectRoot);
|
|
58
|
+
if (found) {
|
|
59
|
+
const parts = symbolPart.split('#');
|
|
60
|
+
const sym = symbols.find((s) => parts.length === 1
|
|
61
|
+
? s.name === parts[0] && !s.parent
|
|
62
|
+
: s.name === parts[1] && s.parent === parts[0]);
|
|
63
|
+
if (sym) {
|
|
64
|
+
line = sym.startLine;
|
|
65
|
+
try {
|
|
66
|
+
const src = await readFile(join(ctx.projectRoot, filePart), 'utf-8');
|
|
67
|
+
const srcLines = src.split('\n');
|
|
68
|
+
const start = sym.startLine - 1;
|
|
69
|
+
const end = Math.min(srcLines.length, start + 5);
|
|
70
|
+
snippet = srcLines.slice(start, end).join('\n');
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// file unreadable
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
outgoingSourceRefs.push({
|
|
79
|
+
target: ref.target,
|
|
80
|
+
file: filePart,
|
|
81
|
+
line,
|
|
82
|
+
snippet,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
42
87
|
const { resolved } = resolveRef(ref.target, sectionIds, fileIndex);
|
|
43
88
|
const resolvedLower = resolved.toLowerCase();
|
|
44
89
|
if (seen.has(resolvedLower))
|
|
@@ -70,7 +115,36 @@ export async function getSection(ctx, query) {
|
|
|
70
115
|
}
|
|
71
116
|
}
|
|
72
117
|
}
|
|
73
|
-
|
|
118
|
+
// Find code back-references: @lat: comments pointing to this section
|
|
119
|
+
const codeRefs = [];
|
|
120
|
+
const { refs: scannedRefs } = await scanCodeRefs(ctx.projectRoot);
|
|
121
|
+
for (const ref of scannedRefs) {
|
|
122
|
+
const { resolved: codeResolved } = resolveRef(ref.target, sectionIds, fileIndex);
|
|
123
|
+
if (codeResolved.toLowerCase() === sectionId) {
|
|
124
|
+
const absFile = join(ctx.projectRoot, ref.file);
|
|
125
|
+
let snippet = '';
|
|
126
|
+
try {
|
|
127
|
+
const src = await readFile(absFile, 'utf-8');
|
|
128
|
+
const srcLines = src.split('\n');
|
|
129
|
+
const start = Math.max(0, ref.line - 1 - 2);
|
|
130
|
+
const end = Math.min(srcLines.length, ref.line - 1 + 3);
|
|
131
|
+
snippet = srcLines.slice(start, end).join('\n');
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// file unreadable — skip snippet
|
|
135
|
+
}
|
|
136
|
+
codeRefs.push({ file: ref.file, line: ref.line, snippet });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
kind: 'found',
|
|
141
|
+
section,
|
|
142
|
+
content,
|
|
143
|
+
outgoingRefs,
|
|
144
|
+
outgoingSourceRefs,
|
|
145
|
+
incomingRefs,
|
|
146
|
+
codeRefs,
|
|
147
|
+
};
|
|
74
148
|
}
|
|
75
149
|
function fullEndLine(section) {
|
|
76
150
|
if (section.children.length === 0)
|
|
@@ -85,7 +159,7 @@ function truncate(s, max) {
|
|
|
85
159
|
*/
|
|
86
160
|
export function formatSectionOutput(ctx, result) {
|
|
87
161
|
const s = ctx.styler;
|
|
88
|
-
const { section, content, outgoingRefs, incomingRefs } = result;
|
|
162
|
+
const { section, content, outgoingRefs, outgoingSourceRefs, incomingRefs, codeRefs, } = result;
|
|
89
163
|
const relPath = relative(process.cwd(), join(ctx.projectRoot, section.filePath));
|
|
90
164
|
const loc = `${s.cyan(relPath)}${s.dim(`:${section.startLine}-${section.endLine}`)}`;
|
|
91
165
|
const quoted = content
|
|
@@ -97,7 +171,7 @@ export function formatSectionOutput(ctx, result) {
|
|
|
97
171
|
'',
|
|
98
172
|
quoted,
|
|
99
173
|
];
|
|
100
|
-
if (outgoingRefs.length > 0) {
|
|
174
|
+
if (outgoingRefs.length > 0 || outgoingSourceRefs.length > 0) {
|
|
101
175
|
parts.push('', '## This section references:', '');
|
|
102
176
|
for (const ref of outgoingRefs) {
|
|
103
177
|
const body = ref.resolved.firstParagraph
|
|
@@ -105,6 +179,18 @@ export function formatSectionOutput(ctx, result) {
|
|
|
105
179
|
: '';
|
|
106
180
|
parts.push(`${s.dim('*')} [[${formatSectionId(ref.resolved.id, s)}]]${body}`);
|
|
107
181
|
}
|
|
182
|
+
for (const ref of outgoingSourceRefs) {
|
|
183
|
+
const loc = ref.line
|
|
184
|
+
? `${s.dim(` (${ref.file}:${ref.line})`)}`
|
|
185
|
+
: `${s.dim(` (${ref.file})`)}`;
|
|
186
|
+
parts.push(`${s.dim('*')} [[${s.cyan(ref.target)}]]${loc}`);
|
|
187
|
+
if (ref.snippet) {
|
|
188
|
+
const snippetLines = ref.snippet.split('\n');
|
|
189
|
+
for (const line of snippetLines) {
|
|
190
|
+
parts.push(` ${s.dim('|')} ${line}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
108
194
|
}
|
|
109
195
|
if (incomingRefs.length > 0) {
|
|
110
196
|
parts.push('', '## Referenced by:', '');
|
|
@@ -115,6 +201,19 @@ export function formatSectionOutput(ctx, result) {
|
|
|
115
201
|
parts.push(`${s.dim('*')} [[${formatSectionId(ref.section.id, s)}]]${body}`);
|
|
116
202
|
}
|
|
117
203
|
}
|
|
204
|
+
if (codeRefs.length > 0) {
|
|
205
|
+
parts.push('', '## Referenced by code:', '');
|
|
206
|
+
for (const ref of codeRefs) {
|
|
207
|
+
const codeRelPath = relative(process.cwd(), join(ctx.projectRoot, ref.file));
|
|
208
|
+
parts.push(`${s.dim('*')} ${s.cyan(codeRelPath)}${s.dim(`:${ref.line}`)}`);
|
|
209
|
+
if (ref.snippet) {
|
|
210
|
+
const snippetLines = ref.snippet.split('\n');
|
|
211
|
+
for (const line of snippetLines) {
|
|
212
|
+
parts.push(` ${s.dim('|')} ${line}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
118
217
|
parts.push(formatNavHints(ctx));
|
|
119
218
|
return parts.join('\n');
|
|
120
219
|
}
|
|
@@ -32,7 +32,7 @@ export async function selectMenu(options, prompt, defaultIndex) {
|
|
|
32
32
|
const pointer = selected ? '❯' : ' ';
|
|
33
33
|
if (selected) {
|
|
34
34
|
if (opt.accent) {
|
|
35
|
-
lines.push(` ${pointer} ${chalk.
|
|
35
|
+
lines.push(` ${pointer} ${chalk.bgGreen.black.bold(` ${opt.label} `)}`);
|
|
36
36
|
}
|
|
37
37
|
else {
|
|
38
38
|
lines.push(` ${pointer} ${chalk.bgCyan.black.bold(` ${opt.label} `)}`);
|
package/dist/src/code-refs.d.ts
CHANGED
|
@@ -10,5 +10,8 @@ export type CodeRef = {
|
|
|
10
10
|
export type ScanResult = {
|
|
11
11
|
refs: CodeRef[];
|
|
12
12
|
files: string[];
|
|
13
|
+
usedRg: boolean;
|
|
13
14
|
};
|
|
15
|
+
/** Check whether ripgrep (`rg`) is available on PATH. */
|
|
16
|
+
export declare function hasRipgrep(): Promise<boolean>;
|
|
14
17
|
export declare function scanCodeRefs(projectRoot: string): Promise<ScanResult>;
|
package/dist/src/code-refs.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
2
3
|
import { join, relative } from 'node:path';
|
|
3
4
|
import { walkEntries } from './walk.js';
|
|
5
|
+
/** Glob patterns used to exclude directories/files from code-ref scanning.
|
|
6
|
+
* Shared between rg args and the TS fallback's walkFiles filter. */
|
|
7
|
+
const EXCLUDE_DIRS = ['lat.md', '.claude'];
|
|
8
|
+
const EXCLUDE_GLOBS = ['*.md'];
|
|
4
9
|
/** Walk project files for code-ref scanning. Uses walkEntries for .gitignore
|
|
5
10
|
* support, then additionally skips .md files, lat.md/, .claude/, and sub-projects. */
|
|
6
11
|
export async function walkFiles(dir) {
|
|
@@ -31,8 +36,141 @@ export const LAT_REF_RE = re('gv') `
|
|
|
31
36
|
( [^\]]+ )
|
|
32
37
|
\]\]
|
|
33
38
|
`;
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Run an external command and return stdout, or null if the command is not found
|
|
41
|
+
* or fails.
|
|
42
|
+
*/
|
|
43
|
+
function tryExec(cmd, args, cwd) {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
execFile(cmd, args, { cwd, maxBuffer: 50 * 1024 * 1024 }, (err, out) => {
|
|
46
|
+
if (err) {
|
|
47
|
+
// Exit code 1 with no stderr typically means "no matches" for grep/rg
|
|
48
|
+
const exitCode = err.code;
|
|
49
|
+
if (exitCode === 'ENOENT') {
|
|
50
|
+
resolve(null); // command not found
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// rg/grep exit 1 = no matches (not an error)
|
|
54
|
+
if ('status' in err &&
|
|
55
|
+
err.status === 1 &&
|
|
56
|
+
out === '') {
|
|
57
|
+
resolve('');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
resolve(null);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
resolve(out);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Detect sub-projects (directories containing their own lat.md/) using
|
|
69
|
+
* rg --files. Finds files inside nested lat.md/ dirs and extracts the parent
|
|
70
|
+
* directory paths. Returns paths relative to projectRoot.
|
|
71
|
+
*/
|
|
72
|
+
async function findSubProjects(projectRoot) {
|
|
73
|
+
// List files inside any lat.md/ dir, then extract unique parent paths.
|
|
74
|
+
// The root lat.md/ is excluded by EXCLUDE_DIRS in the caller, so we only
|
|
75
|
+
// need to find nested ones here — search for files under */lat.md/.
|
|
76
|
+
const out = await tryExec('rg', ['--files', '--glob', '**/lat.md/**', '.'], projectRoot);
|
|
77
|
+
if (!out)
|
|
78
|
+
return [];
|
|
79
|
+
const subProjects = new Set();
|
|
80
|
+
for (const line of out.split('\n')) {
|
|
81
|
+
if (!line)
|
|
82
|
+
continue;
|
|
83
|
+
const clean = line.startsWith('./') ? line.slice(2) : line;
|
|
84
|
+
// "tests/cases/foo/lat.md/specs.md" → "tests/cases/foo"
|
|
85
|
+
// Skip root lat.md/ (no parent prefix — starts with "lat.md/")
|
|
86
|
+
const idx = clean.indexOf('/lat.md/');
|
|
87
|
+
if (idx !== -1)
|
|
88
|
+
subProjects.add(clean.slice(0, idx));
|
|
89
|
+
}
|
|
90
|
+
return [...subProjects];
|
|
91
|
+
}
|
|
92
|
+
/** Build rg glob exclusion args. */
|
|
93
|
+
function rgExcludeArgs(subProjects) {
|
|
94
|
+
const args = [];
|
|
95
|
+
for (const dir of EXCLUDE_DIRS)
|
|
96
|
+
args.push('--glob', `!${dir}/`);
|
|
97
|
+
for (const glob of EXCLUDE_GLOBS)
|
|
98
|
+
args.push('--glob', `!${glob}`);
|
|
99
|
+
for (const sp of subProjects)
|
|
100
|
+
args.push('--glob', `!${sp}/`);
|
|
101
|
+
return args;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Try scanning with ripgrep. Returns parsed refs and scanned file list, or null
|
|
105
|
+
* if rg is not available. rg respects .gitignore by default; we add glob
|
|
106
|
+
* exclusions for lat.md/, .claude/, *.md files, and sub-projects.
|
|
107
|
+
*/
|
|
108
|
+
async function tryRipgrep(projectRoot) {
|
|
109
|
+
// Detect sub-projects first so we can exclude them from all rg calls
|
|
110
|
+
const subProjects = await findSubProjects(projectRoot);
|
|
111
|
+
const excludes = rgExcludeArgs(subProjects);
|
|
112
|
+
// Search for @lat refs
|
|
113
|
+
const searchArgs = [
|
|
114
|
+
'--no-heading',
|
|
115
|
+
'--line-number',
|
|
116
|
+
'--with-filename',
|
|
117
|
+
...excludes,
|
|
118
|
+
'@lat:.*\\[\\[',
|
|
119
|
+
'.',
|
|
120
|
+
];
|
|
121
|
+
const out = await tryExec('rg', searchArgs, projectRoot);
|
|
122
|
+
if (out === null)
|
|
123
|
+
return null;
|
|
124
|
+
const { refs } = parseGrepOutput(out, projectRoot);
|
|
125
|
+
// List all scanned files (for stats) — rg --files is fast
|
|
126
|
+
const filesOut = await tryExec('rg', ['--files', ...excludes, '.'], projectRoot);
|
|
127
|
+
const files = (filesOut || '')
|
|
128
|
+
.split('\n')
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
.map((f) => {
|
|
131
|
+
const clean = f.startsWith('./') ? f.slice(2) : f;
|
|
132
|
+
return join(projectRoot, clean);
|
|
133
|
+
});
|
|
134
|
+
return { refs, files };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Parse rg output lines (file:line:content) into CodeRef entries.
|
|
138
|
+
*/
|
|
139
|
+
function parseGrepOutput(output, projectRoot) {
|
|
140
|
+
const refs = [];
|
|
141
|
+
if (!output.trim())
|
|
142
|
+
return { refs };
|
|
143
|
+
for (const line of output.split('\n')) {
|
|
144
|
+
if (!line)
|
|
145
|
+
continue;
|
|
146
|
+
// Format: ./path/to/file:linenum:content
|
|
147
|
+
const firstColon = line.indexOf(':');
|
|
148
|
+
if (firstColon === -1)
|
|
149
|
+
continue;
|
|
150
|
+
const secondColon = line.indexOf(':', firstColon + 1);
|
|
151
|
+
if (secondColon === -1)
|
|
152
|
+
continue;
|
|
153
|
+
let filePath = line.slice(0, firstColon);
|
|
154
|
+
const lineNum = parseInt(line.slice(firstColon + 1, secondColon), 10);
|
|
155
|
+
const content = line.slice(secondColon + 1);
|
|
156
|
+
if (isNaN(lineNum))
|
|
157
|
+
continue;
|
|
158
|
+
// Strip leading ./ from path
|
|
159
|
+
if (filePath.startsWith('./'))
|
|
160
|
+
filePath = filePath.slice(2);
|
|
161
|
+
// Extract targets using the same regex as the TS fallback
|
|
162
|
+
LAT_REF_RE.lastIndex = 0;
|
|
163
|
+
let match;
|
|
164
|
+
while ((match = LAT_REF_RE.exec(content)) !== null) {
|
|
165
|
+
refs.push({ target: match[1], file: filePath, line: lineNum });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { refs };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* TypeScript fallback: read every file and scan for @lat refs.
|
|
172
|
+
*/
|
|
173
|
+
async function scanWithTs(files, projectRoot) {
|
|
36
174
|
const refs = [];
|
|
37
175
|
for (const file of files) {
|
|
38
176
|
let content;
|
|
@@ -50,11 +188,30 @@ export async function scanCodeRefs(projectRoot) {
|
|
|
50
188
|
while ((match = LAT_REF_RE.exec(lines[i])) !== null) {
|
|
51
189
|
refs.push({
|
|
52
190
|
target: match[1],
|
|
53
|
-
file: relative(
|
|
191
|
+
file: relative(projectRoot, file),
|
|
54
192
|
line: i + 1,
|
|
55
193
|
});
|
|
56
194
|
}
|
|
57
195
|
}
|
|
58
196
|
}
|
|
59
|
-
return
|
|
197
|
+
return refs;
|
|
198
|
+
}
|
|
199
|
+
/** Check whether ripgrep (`rg`) is available on PATH. */
|
|
200
|
+
export async function hasRipgrep() {
|
|
201
|
+
const result = await tryExec('rg', ['--version'], '.');
|
|
202
|
+
return result !== null;
|
|
203
|
+
}
|
|
204
|
+
export async function scanCodeRefs(projectRoot) {
|
|
205
|
+
// Fast path: use rg for both searching and file listing
|
|
206
|
+
// _LAT_DISABLE_RG is a test-only escape hatch to force the TS fallback
|
|
207
|
+
if (process.env._LAT_DISABLE_RG !== '1') {
|
|
208
|
+
const rgResult = await tryRipgrep(projectRoot);
|
|
209
|
+
if (rgResult !== null) {
|
|
210
|
+
return { refs: rgResult.refs, files: rgResult.files, usedRg: true };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Fallback: walk files ourselves and scan with TS
|
|
214
|
+
const files = await walkFiles(projectRoot);
|
|
215
|
+
const refs = await scanWithTs(files, projectRoot);
|
|
216
|
+
return { refs, files, usedRg: false };
|
|
60
217
|
}
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -50,7 +50,7 @@ export async function startMcpServer() {
|
|
|
50
50
|
scope: z
|
|
51
51
|
.enum(['md', 'code', 'md+code'])
|
|
52
52
|
.optional()
|
|
53
|
-
.default('md')
|
|
53
|
+
.default('md+code')
|
|
54
54
|
.describe('Where to search: md, code, or md+code'),
|
|
55
55
|
}, async ({ query, scope }) => toMcp(await refsCommand(ctx, query, scope)));
|
|
56
56
|
const transport = new StdioServerTransport();
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
2
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
|
-
import { keyHint } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
|
|
4
4
|
import type { Theme } from "@mariozechner/pi-tui";
|
|
5
|
-
import { Box, Text } from "@mariozechner/pi-tui";
|
|
5
|
+
import { Box, Markdown, Text } from "@mariozechner/pi-tui";
|
|
6
6
|
|
|
7
7
|
const PREVIEW_LINES = 4;
|
|
8
8
|
|
|
@@ -14,10 +14,11 @@ function collapsibleResult(
|
|
|
14
14
|
const text = result.content?.[0]?.type === "text" ? (result.content[0] as { type: "text"; text: string }).text : "";
|
|
15
15
|
if (!text) return new Text(theme.fg("dim", "(empty)"), 0, 0);
|
|
16
16
|
if (options.isPartial) return new Text(theme.fg("dim", "…"), 0, 0);
|
|
17
|
-
|
|
17
|
+
const mdTheme = getMarkdownTheme();
|
|
18
|
+
if (options.expanded) return new Markdown(text, 0, 0, mdTheme);
|
|
18
19
|
|
|
19
20
|
const lines = text.split("\n");
|
|
20
|
-
if (lines.length <= PREVIEW_LINES) return new
|
|
21
|
+
if (lines.length <= PREVIEW_LINES) return new Markdown(text, 0, 0, mdTheme);
|
|
21
22
|
|
|
22
23
|
const preview = lines.slice(0, PREVIEW_LINES).join("\n");
|
|
23
24
|
const remaining = lines.length - PREVIEW_LINES;
|
|
@@ -223,7 +224,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
223
224
|
pi.registerMessageRenderer("lat-reminder", (message, { expanded }, theme) => {
|
|
224
225
|
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
|
225
226
|
if (expanded) {
|
|
226
|
-
box.addChild(new Text(theme.fg("accent", "lat.md")
|
|
227
|
+
box.addChild(new Text(theme.fg("accent", "lat.md"), 0, 0));
|
|
228
|
+
box.addChild(new Markdown(message.content, 0, 0, getMarkdownTheme()));
|
|
227
229
|
} else {
|
|
228
230
|
const hint = keyHint("expandTools", "to expand");
|
|
229
231
|
box.addChild(new Text(
|
|
@@ -238,7 +240,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
238
240
|
pi.registerMessageRenderer("lat-check", (message, { expanded }, theme) => {
|
|
239
241
|
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
|
240
242
|
if (expanded) {
|
|
241
|
-
box.addChild(new Text(theme.fg("warning", "lat check")
|
|
243
|
+
box.addChild(new Text(theme.fg("warning", "lat check"), 0, 0));
|
|
244
|
+
box.addChild(new Markdown(message.content, 0, 0, getMarkdownTheme()));
|
|
242
245
|
} else {
|
|
243
246
|
const hint = keyHint("expandTools", "to expand");
|
|
244
247
|
const firstLine = message.content.split("\n")[0];
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lat-md
|
|
3
|
+
description: >-
|
|
4
|
+
Writing and maintaining lat.md documentation files — structured markdown that
|
|
5
|
+
describes a project's architecture, design decisions, and test specs. Use when
|
|
6
|
+
creating, editing, or reviewing files in the lat.md/ directory.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# lat.md Authoring Guide
|
|
10
|
+
|
|
11
|
+
This skill covers the syntax, structure rules, and conventions for writing `lat.md/` files. Load it whenever you need to create or edit sections in the `lat.md/` directory.
|
|
12
|
+
|
|
13
|
+
## What belongs in lat.md
|
|
14
|
+
|
|
15
|
+
`lat.md/` files describe **what** the project does and **why** — domain concepts, key design decisions, business logic, and test specifications. They do NOT duplicate source code. Think of each section as an anchor that source code references back to.
|
|
16
|
+
|
|
17
|
+
Good candidates for sections:
|
|
18
|
+
- Architecture decisions and their rationale
|
|
19
|
+
- Domain concepts and business rules
|
|
20
|
+
- API contracts and protocols
|
|
21
|
+
- Test specifications (what is tested and why)
|
|
22
|
+
- Non-obvious constraints or invariants
|
|
23
|
+
|
|
24
|
+
Bad candidates:
|
|
25
|
+
- Step-by-step code walkthroughs (the code itself is the walkthrough)
|
|
26
|
+
- Auto-generated API docs (use tools for that)
|
|
27
|
+
- Temporary notes or TODOs
|
|
28
|
+
|
|
29
|
+
## Section structure
|
|
30
|
+
|
|
31
|
+
Every section **must** have a leading paragraph — at least one sentence immediately after the heading, before any child headings or other block content.
|
|
32
|
+
|
|
33
|
+
The first paragraph must be ≤250 characters (excluding `[[wiki link]]` content). This paragraph is the section's identity — it appears in search results, command output, and RAG context.
|
|
34
|
+
|
|
35
|
+
```markdown
|
|
36
|
+
# Good Section
|
|
37
|
+
|
|
38
|
+
Brief overview of what this section documents and why it matters.
|
|
39
|
+
|
|
40
|
+
More detail can go in subsequent paragraphs, code blocks, or lists.
|
|
41
|
+
|
|
42
|
+
## Child heading
|
|
43
|
+
|
|
44
|
+
Details about this child topic.
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```markdown
|
|
48
|
+
# Bad Section
|
|
49
|
+
|
|
50
|
+
## Child heading
|
|
51
|
+
|
|
52
|
+
This is invalid — "Bad Section" has no leading paragraph.
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`lat check` enforces this rule.
|
|
56
|
+
|
|
57
|
+
## Section IDs
|
|
58
|
+
|
|
59
|
+
Sections are addressed by file path and heading chain:
|
|
60
|
+
|
|
61
|
+
- **Full form**: `lat.md/path/to/file#Heading#SubHeading`
|
|
62
|
+
- **Short form**: `file#Heading#SubHeading` (when the file stem is unique)
|
|
63
|
+
|
|
64
|
+
Examples: `lat.md/tests/search#RAG Replay Tests`, `cli#init`, `parser#Wiki Links`.
|
|
65
|
+
|
|
66
|
+
## Wiki links
|
|
67
|
+
|
|
68
|
+
Cross-reference other sections or source code with `[[target]]` or `[[target|alias]]`.
|
|
69
|
+
|
|
70
|
+
### Section links
|
|
71
|
+
|
|
72
|
+
```markdown
|
|
73
|
+
See [[cli#init]] for setup details.
|
|
74
|
+
The parser validates [[parser#Wiki Links|wiki link syntax]].
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Source code links
|
|
78
|
+
|
|
79
|
+
Reference functions, classes, constants, and methods in source files:
|
|
80
|
+
|
|
81
|
+
```markdown
|
|
82
|
+
[[src/config.ts#getConfigDir]] — function
|
|
83
|
+
[[src/server.ts#App#listen]] — class method
|
|
84
|
+
[[lib/utils.py#parse_args]] — Python function
|
|
85
|
+
[[src/lib.rs#Greeter#greet]] — Rust impl method
|
|
86
|
+
[[src/app.go#Greeter#Greet]] — Go method
|
|
87
|
+
[[src/app.h#Greeter]] — C struct
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`lat check` validates that all targets exist.
|
|
91
|
+
|
|
92
|
+
## Code refs
|
|
93
|
+
|
|
94
|
+
Tie source code back to `lat.md/` sections with `@lat:` comments:
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// @lat: [[cli#init]]
|
|
98
|
+
export function init() { ... }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# @lat: [[cli#init]]
|
|
103
|
+
def init():
|
|
104
|
+
...
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Supported comment styles: `//` (JS/TS/Rust/Go/C) and `#` (Python).
|
|
108
|
+
|
|
109
|
+
Place one `@lat:` comment per section, at the relevant code — not at the top of the file.
|
|
110
|
+
|
|
111
|
+
## Test specs
|
|
112
|
+
|
|
113
|
+
Describe tests as sections in `lat.md/` files. Add frontmatter to require that every leaf section has a matching `@lat:` comment in test code:
|
|
114
|
+
|
|
115
|
+
```markdown
|
|
116
|
+
---
|
|
117
|
+
lat:
|
|
118
|
+
require-code-mention: true
|
|
119
|
+
---
|
|
120
|
+
# Tests
|
|
121
|
+
|
|
122
|
+
Authentication test specifications.
|
|
123
|
+
|
|
124
|
+
## User login
|
|
125
|
+
|
|
126
|
+
Verify credential validation and error handling.
|
|
127
|
+
|
|
128
|
+
### Rejects expired tokens
|
|
129
|
+
|
|
130
|
+
Tokens past their expiry timestamp are rejected with 401, even if otherwise valid.
|
|
131
|
+
|
|
132
|
+
### Handles missing password
|
|
133
|
+
|
|
134
|
+
Login request without a password field returns 400 with a descriptive error.
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Each test references its spec:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
# @lat: [[tests#User login#Rejects expired tokens]]
|
|
141
|
+
def test_rejects_expired_tokens():
|
|
142
|
+
...
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Rules:
|
|
146
|
+
- Every leaf section under `require-code-mention: true` must be referenced by exactly one `@lat:` comment
|
|
147
|
+
- Every section MUST have a description — at least one sentence explaining what the test verifies and why
|
|
148
|
+
- `lat check` flags unreferenced specs and dangling code refs
|
|
149
|
+
|
|
150
|
+
## Frontmatter
|
|
151
|
+
|
|
152
|
+
Optional YAML frontmatter at the top of `lat.md/` files:
|
|
153
|
+
|
|
154
|
+
```yaml
|
|
155
|
+
---
|
|
156
|
+
lat:
|
|
157
|
+
require-code-mention: true
|
|
158
|
+
---
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Currently the only supported field is `require-code-mention` for test spec enforcement.
|
|
162
|
+
|
|
163
|
+
## Validation
|
|
164
|
+
|
|
165
|
+
Always run `lat check` after editing `lat.md/` files. It validates:
|
|
166
|
+
- All wiki links point to existing sections or source code symbols
|
|
167
|
+
- All `@lat:` code refs point to existing sections
|
|
168
|
+
- Every section has a leading paragraph (≤250 chars)
|
|
169
|
+
- All `require-code-mention` leaf sections are referenced in code
|