@plebeiantech/lat.md 0.12.3-fork.1
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/LICENSE +21 -0
- package/README.md +97 -0
- package/dist/src/cli/check-frontmatter.d.ts +12 -0
- package/dist/src/cli/check-frontmatter.js +44 -0
- package/dist/src/cli/check-mode.d.ts +6 -0
- package/dist/src/cli/check-mode.js +303 -0
- package/dist/src/cli/check-status.d.ts +74 -0
- package/dist/src/cli/check-status.js +217 -0
- package/dist/src/cli/check.d.ts +45 -0
- package/dist/src/cli/check.js +777 -0
- package/dist/src/cli/checklist-menu.d.ts +12 -0
- package/dist/src/cli/checklist-menu.js +111 -0
- package/dist/src/cli/comment-guard.d.ts +16 -0
- package/dist/src/cli/comment-guard.js +111 -0
- package/dist/src/cli/comment-reminder.d.ts +62 -0
- package/dist/src/cli/comment-reminder.js +210 -0
- package/dist/src/cli/context.d.ts +11 -0
- package/dist/src/cli/context.js +58 -0
- package/dist/src/cli/expand.d.ts +8 -0
- package/dist/src/cli/expand.js +94 -0
- package/dist/src/cli/gen-index.d.ts +45 -0
- package/dist/src/cli/gen-index.js +235 -0
- package/dist/src/cli/gen.d.ts +6 -0
- package/dist/src/cli/gen.js +42 -0
- package/dist/src/cli/graph.d.ts +7 -0
- package/dist/src/cli/graph.js +47 -0
- package/dist/src/cli/hook.d.ts +1 -0
- package/dist/src/cli/hook.js +432 -0
- package/dist/src/cli/index.d.ts +2 -0
- package/dist/src/cli/index.js +307 -0
- package/dist/src/cli/init.d.ts +9 -0
- package/dist/src/cli/init.js +1031 -0
- package/dist/src/cli/link-scheme.d.ts +54 -0
- package/dist/src/cli/link-scheme.js +101 -0
- package/dist/src/cli/locate.d.ts +2 -0
- package/dist/src/cli/locate.js +17 -0
- package/dist/src/cli/refs.d.ts +22 -0
- package/dist/src/cli/refs.js +229 -0
- package/dist/src/cli/reindex.d.ts +13 -0
- package/dist/src/cli/reindex.js +136 -0
- package/dist/src/cli/search.d.ts +36 -0
- package/dist/src/cli/search.js +217 -0
- package/dist/src/cli/section.d.ts +43 -0
- package/dist/src/cli/section.js +252 -0
- package/dist/src/cli/select-menu.d.ts +13 -0
- package/dist/src/cli/select-menu.js +100 -0
- package/dist/src/cli/templates.d.ts +1 -0
- package/dist/src/cli/templates.js +15 -0
- package/dist/src/cli/ui.d.ts +11 -0
- package/dist/src/cli/ui.js +34 -0
- package/dist/src/code-refs.d.ts +17 -0
- package/dist/src/code-refs.js +277 -0
- package/dist/src/config.d.ts +29 -0
- package/dist/src/config.js +83 -0
- package/dist/src/context.d.ts +23 -0
- package/dist/src/context.js +11 -0
- package/dist/src/extensions/wiki-link/from-markdown.d.ts +5 -0
- package/dist/src/extensions/wiki-link/from-markdown.js +34 -0
- package/dist/src/extensions/wiki-link/index.d.ts +4 -0
- package/dist/src/extensions/wiki-link/index.js +3 -0
- package/dist/src/extensions/wiki-link/syntax.d.ts +14 -0
- package/dist/src/extensions/wiki-link/syntax.js +121 -0
- package/dist/src/extensions/wiki-link/to-markdown.d.ts +5 -0
- package/dist/src/extensions/wiki-link/to-markdown.js +28 -0
- package/dist/src/extensions/wiki-link/types.d.ts +34 -0
- package/dist/src/extensions/wiki-link/types.js +1 -0
- package/dist/src/format.d.ts +8 -0
- package/dist/src/format.js +43 -0
- package/dist/src/graph-export.d.ts +60 -0
- package/dist/src/graph-export.js +309 -0
- package/dist/src/init-version.d.ts +10 -0
- package/dist/src/init-version.js +49 -0
- package/dist/src/knowledge/bd.d.ts +2 -0
- package/dist/src/knowledge/bd.js +107 -0
- package/dist/src/knowledge/claude-memory.d.ts +13 -0
- package/dist/src/knowledge/claude-memory.js +204 -0
- package/dist/src/knowledge/cq.d.ts +2 -0
- package/dist/src/knowledge/cq.js +74 -0
- package/dist/src/knowledge/index.d.ts +49 -0
- package/dist/src/knowledge/index.js +271 -0
- package/dist/src/knowledge/ranking.d.ts +17 -0
- package/dist/src/knowledge/ranking.js +62 -0
- package/dist/src/knowledge/session.d.ts +46 -0
- package/dist/src/knowledge/session.js +203 -0
- package/dist/src/knowledge/types.d.ts +47 -0
- package/dist/src/knowledge/types.js +10 -0
- package/dist/src/lattice.d.ts +108 -0
- package/dist/src/lattice.js +816 -0
- package/dist/src/mcp/server.d.ts +1 -0
- package/dist/src/mcp/server.js +58 -0
- package/dist/src/parser.d.ts +3 -0
- package/dist/src/parser.js +18 -0
- package/dist/src/search/db.d.ts +16 -0
- package/dist/src/search/db.js +54 -0
- package/dist/src/search/embedder.d.ts +25 -0
- package/dist/src/search/embedder.js +58 -0
- package/dist/src/search/index.d.ts +9 -0
- package/dist/src/search/index.js +66 -0
- package/dist/src/search/search.d.ts +9 -0
- package/dist/src/search/search.js +16 -0
- package/dist/src/source-parser.d.ts +27 -0
- package/dist/src/source-parser.js +916 -0
- package/dist/src/untrusted.d.ts +24 -0
- package/dist/src/untrusted.js +97 -0
- package/dist/src/version.d.ts +7 -0
- package/dist/src/version.js +39 -0
- package/dist/src/view/client/assets/index-B0n0u03y.css +1 -0
- package/dist/src/view/client/assets/index-xm2_px4q.js +23 -0
- package/dist/src/view/client/index.html +14 -0
- package/dist/src/view/diagnostics.d.ts +5 -0
- package/dist/src/view/diagnostics.js +171 -0
- package/dist/src/view/git-diff.d.ts +3 -0
- package/dist/src/view/git-diff.js +255 -0
- package/dist/src/view/git.d.ts +22 -0
- package/dist/src/view/git.js +193 -0
- package/dist/src/view/highlight.d.ts +2 -0
- package/dist/src/view/highlight.js +3767 -0
- package/dist/src/view/markdown.d.ts +22 -0
- package/dist/src/view/markdown.js +322 -0
- package/dist/src/view/protocol.d.ts +89 -0
- package/dist/src/view/protocol.js +1 -0
- package/dist/src/view/references.d.ts +62 -0
- package/dist/src/view/references.js +345 -0
- package/dist/src/view/repository.d.ts +12 -0
- package/dist/src/view/repository.js +167 -0
- package/dist/src/view/search.d.ts +14 -0
- package/dist/src/view/search.js +64 -0
- package/dist/src/view/server.d.ts +20 -0
- package/dist/src/view/server.js +233 -0
- package/dist/src/view/store.d.ts +57 -0
- package/dist/src/view/store.js +404 -0
- package/dist/src/walk.d.ts +18 -0
- package/dist/src/walk.js +27 -0
- package/package.json +85 -0
- package/templates/AGENTS.md +118 -0
- package/templates/README +1 -0
- package/templates/cursor-rules.md +77 -0
- package/templates/init/lat.md +1 -0
- package/templates/logo-dark.svg +182 -0
- package/templates/logo-light.svg +182 -0
- package/templates/logo.txt +7 -0
- package/templates/opencode-plugin.ts +180 -0
- package/templates/pi-extension.ts +364 -0
- package/templates/skill/SKILL.md +189 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mdast-util extension to serialize wiki-link nodes back to markdown.
|
|
3
|
+
*/
|
|
4
|
+
function handler(node, _parent, state, _info) {
|
|
5
|
+
const exit = state.enter('wikiLink');
|
|
6
|
+
const target = state.safe(node.value, { before: '[', after: ']' });
|
|
7
|
+
let value;
|
|
8
|
+
if (node.data.alias) {
|
|
9
|
+
const alias = state.safe(node.data.alias, { before: '[', after: ']' });
|
|
10
|
+
value = `[[${target}|${alias}]]`;
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
value = `[[${target}]]`;
|
|
14
|
+
}
|
|
15
|
+
exit();
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
export function wikiLinkToMarkdown() {
|
|
19
|
+
return {
|
|
20
|
+
unsafe: [
|
|
21
|
+
{ character: '[', inConstruct: ['phrasing', 'label', 'reference'] },
|
|
22
|
+
{ character: ']', inConstruct: ['label', 'reference'] },
|
|
23
|
+
],
|
|
24
|
+
handlers: {
|
|
25
|
+
wikiLink: handler,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Data, Literal } from 'mdast';
|
|
2
|
+
/**
|
|
3
|
+
* mdast node for a wiki-style link like [[target]] or [[target|alias]].
|
|
4
|
+
*/
|
|
5
|
+
export interface WikiLink extends Literal {
|
|
6
|
+
type: 'wikiLink';
|
|
7
|
+
value: string;
|
|
8
|
+
data: Data & {
|
|
9
|
+
alias: string | null;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
declare module 'mdast' {
|
|
13
|
+
interface RootContentMap {
|
|
14
|
+
wikiLink: WikiLink;
|
|
15
|
+
}
|
|
16
|
+
interface PhrasingContentMap {
|
|
17
|
+
wikiLink: WikiLink;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
declare module 'micromark-util-types' {
|
|
21
|
+
interface TokenTypeMap {
|
|
22
|
+
wikiLink: 'wikiLink';
|
|
23
|
+
wikiLinkMarker: 'wikiLinkMarker';
|
|
24
|
+
wikiLinkData: 'wikiLinkData';
|
|
25
|
+
wikiLinkTarget: 'wikiLinkTarget';
|
|
26
|
+
wikiLinkAliasMarker: 'wikiLinkAliasMarker';
|
|
27
|
+
wikiLinkAlias: 'wikiLinkAlias';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
declare module 'mdast-util-to-markdown' {
|
|
31
|
+
interface ConstructNameMap {
|
|
32
|
+
wikiLink: 'wikiLink';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Section, SectionMatch } from './lattice.js';
|
|
2
|
+
import type { CmdContext, Styler } from './context.js';
|
|
3
|
+
export declare function formatSectionId(id: string, s: Styler): string;
|
|
4
|
+
export declare function formatSectionPreview(ctx: CmdContext, section: Section, opts?: {
|
|
5
|
+
reason?: string;
|
|
6
|
+
}): string;
|
|
7
|
+
export declare function formatResultList(ctx: CmdContext, header: string, matches: SectionMatch[]): string;
|
|
8
|
+
export declare function formatNavHints(ctx: CmdContext): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { join, relative } from 'node:path';
|
|
2
|
+
export function formatSectionId(id, s) {
|
|
3
|
+
const parts = id.split('#');
|
|
4
|
+
return parts.length === 1
|
|
5
|
+
? s.boldWhite(parts[0])
|
|
6
|
+
: s.dim(parts.slice(0, -1).join('#') + '#') +
|
|
7
|
+
s.boldWhite(parts[parts.length - 1]);
|
|
8
|
+
}
|
|
9
|
+
export function formatSectionPreview(ctx, section, opts) {
|
|
10
|
+
const s = ctx.styler;
|
|
11
|
+
const relPath = relative(process.cwd(), join(ctx.projectRoot, section.filePath));
|
|
12
|
+
const kind = section.id.includes('#') ? 'Section' : 'File';
|
|
13
|
+
const reasonSuffix = opts?.reason ? ' ' + s.dim(`(${opts.reason})`) : '';
|
|
14
|
+
const lines = [
|
|
15
|
+
`${s.dim('*')} ${s.dim(kind + ':')} [[${formatSectionId(section.id, s)}]]${reasonSuffix}`,
|
|
16
|
+
` ${s.dim('Defined in')} ${s.cyan(relPath)}${s.dim(`:${section.startLine}-${section.endLine}`)}`,
|
|
17
|
+
];
|
|
18
|
+
if (section.firstParagraph) {
|
|
19
|
+
lines.push('', ` ${s.dim('>')} ${section.firstParagraph}`);
|
|
20
|
+
}
|
|
21
|
+
return lines.join('\n');
|
|
22
|
+
}
|
|
23
|
+
export function formatResultList(ctx, header, matches) {
|
|
24
|
+
const lines = ['', `## ${header}`, ''];
|
|
25
|
+
for (let i = 0; i < matches.length; i++) {
|
|
26
|
+
if (i > 0)
|
|
27
|
+
lines.push('');
|
|
28
|
+
lines.push(formatSectionPreview(ctx, matches[i].section, {
|
|
29
|
+
reason: matches[i].reason,
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
lines.push('');
|
|
33
|
+
return lines.join('\n');
|
|
34
|
+
}
|
|
35
|
+
export function formatNavHints(ctx) {
|
|
36
|
+
const s = ctx.styler;
|
|
37
|
+
const hints = ctx.mode === 'cli'
|
|
38
|
+
? `${s.dim('*')} \`lat section "section#id"\` \u2014 show full content with outgoing/incoming refs\n` +
|
|
39
|
+
`${s.dim('*')} \`lat search "new query"\` \u2014 search for something else`
|
|
40
|
+
: `${s.dim('*')} \`lat_section\` \u2014 show full content with outgoing/incoming refs\n` +
|
|
41
|
+
`${s.dim('*')} \`lat_search\` \u2014 search for something else`;
|
|
42
|
+
return `\n## To navigate further:\n\n${hints}`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export type GraphNodeType = 'document' | 'section' | 'code' | 'tag';
|
|
2
|
+
export type GraphEdgeType = 'contains' | 'wikilink' | 'code-ref' | 'tag';
|
|
3
|
+
export type GraphNode = {
|
|
4
|
+
id: string;
|
|
5
|
+
type: GraphNodeType;
|
|
6
|
+
label: string;
|
|
7
|
+
file?: string;
|
|
8
|
+
mode?: string;
|
|
9
|
+
status?: string;
|
|
10
|
+
reviewedHash?: string;
|
|
11
|
+
};
|
|
12
|
+
export type GraphEdge = {
|
|
13
|
+
from: string;
|
|
14
|
+
to: string;
|
|
15
|
+
type: GraphEdgeType;
|
|
16
|
+
};
|
|
17
|
+
export type Graph = {
|
|
18
|
+
nodes: GraphNode[];
|
|
19
|
+
edges: GraphEdge[];
|
|
20
|
+
};
|
|
21
|
+
/** A lat.md markdown file's project-root-relative path and content. */
|
|
22
|
+
export type GraphSourceFile = {
|
|
23
|
+
/** Project-root-relative, posix-separated path (e.g. "lat.md/tests.md"). */
|
|
24
|
+
relPath: string;
|
|
25
|
+
content: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Build the knowledge graph from a set of already-loaded lat.md file
|
|
29
|
+
* contents. Used for both the working tree and a reconstructed git
|
|
30
|
+
* revision -- the only difference between the two call sites is where the
|
|
31
|
+
* file contents came from.
|
|
32
|
+
*
|
|
33
|
+
* `includeCode` controls whether the code-ref and source-wikilink halves of
|
|
34
|
+
* the graph are built by scanning the live project tree on disk. This is
|
|
35
|
+
* only meaningful for the working tree: a git revision's source tree is not
|
|
36
|
+
* reconstructed, only its lat.md/ documents are, so `includeCode` is false
|
|
37
|
+
* for `--at` / `--since`.
|
|
38
|
+
*/
|
|
39
|
+
export declare function buildGraph(files: GraphSourceFile[], projectRoot: string, includeCode: boolean): Promise<Graph>;
|
|
40
|
+
/** Read the working tree's lat.md files straight off disk. */
|
|
41
|
+
export declare function loadWorkingTreeFiles(latticeDir: string, projectRoot: string): Promise<GraphSourceFile[]>;
|
|
42
|
+
/**
|
|
43
|
+
* Reconstruct the lat.md/ markdown tree as it existed at a git revision.
|
|
44
|
+
* Only the documents are reconstructed, not the surrounding source tree —
|
|
45
|
+
* `buildGraph` is called with `includeCode: false` for these.
|
|
46
|
+
*/
|
|
47
|
+
export declare function loadRevisionFiles(projectRoot: string, latticeDir: string, rev: string): Promise<GraphSourceFile[]>;
|
|
48
|
+
export type GraphDiff = {
|
|
49
|
+
added: string[];
|
|
50
|
+
removed: string[];
|
|
51
|
+
changed: string[];
|
|
52
|
+
};
|
|
53
|
+
/** Diff two graphs' section nodes by id, reporting added/removed/changed. */
|
|
54
|
+
export declare function diffGraphs(before: Graph, after: Graph): GraphDiff;
|
|
55
|
+
export declare function formatGraphJson(graph: Graph): string;
|
|
56
|
+
export declare function formatGraphMermaid(graph: Graph): string;
|
|
57
|
+
export declare function formatGraphDot(graph: Graph): string;
|
|
58
|
+
export type GraphFormat = 'json' | 'mermaid' | 'dot';
|
|
59
|
+
export declare function formatGraph(graph: Graph, format: GraphFormat): string;
|
|
60
|
+
export declare function formatGraphDiff(diff: GraphDiff): string;
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// Export the whole lat.md knowledge graph: sections, documents, code refs,
|
|
2
|
+
// wiki links, and test-spec coverage, at the working tree or at a git
|
|
3
|
+
// revision. See lat-t1y.10.
|
|
4
|
+
//
|
|
5
|
+
// Every label placed into a mermaid or dot rendering is repository text, so
|
|
6
|
+
// it is passed through `cleanUntrustedId` before it becomes a node id or
|
|
7
|
+
// label -- never a hand-rolled sanitizer. `cleanUntrustedId` (not
|
|
8
|
+
// `quoteUntrusted`) is correct here: node ids and labels must not be
|
|
9
|
+
// truncated, and quoting them would corrupt them as identifiers.
|
|
10
|
+
import { execFile } from 'node:child_process';
|
|
11
|
+
import { join, relative } from 'node:path';
|
|
12
|
+
import { promisify } from 'node:util';
|
|
13
|
+
import { extractRefs, buildFileIndex, buildSectionSlugIndex, parseSections, parseFrontmatter, resolveRef, } from './lattice.js';
|
|
14
|
+
import { scanCodeRefs } from './code-refs.js';
|
|
15
|
+
import { DIATAXIS_MODES, MODE_DIRS } from './cli/check-mode.js';
|
|
16
|
+
import { readProvenance } from './cli/check-status.js';
|
|
17
|
+
import { cleanUntrustedId } from './untrusted.js';
|
|
18
|
+
import { toPosix } from './walk.js';
|
|
19
|
+
const execFileAsync = promisify(execFile);
|
|
20
|
+
function docId(relPath) {
|
|
21
|
+
return `doc:${relPath}`;
|
|
22
|
+
}
|
|
23
|
+
function sectionId(id) {
|
|
24
|
+
return `section:${id}`;
|
|
25
|
+
}
|
|
26
|
+
function codeId(target) {
|
|
27
|
+
return `code:${target}`;
|
|
28
|
+
}
|
|
29
|
+
function tagId(tag) {
|
|
30
|
+
return `tag:${tag}`;
|
|
31
|
+
}
|
|
32
|
+
function modeForFile(relPath, fm) {
|
|
33
|
+
const declared = fm['mode'];
|
|
34
|
+
if (typeof declared === 'string')
|
|
35
|
+
return declared;
|
|
36
|
+
for (const mode of DIATAXIS_MODES) {
|
|
37
|
+
const dir = MODE_DIRS[mode];
|
|
38
|
+
if (relPath.includes(`/${dir}/`) || relPath.startsWith(`${dir}/`)) {
|
|
39
|
+
return mode;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
function tagsForFile(fm) {
|
|
45
|
+
const raw = fm['tags'];
|
|
46
|
+
if (Array.isArray(raw))
|
|
47
|
+
return raw.filter((t) => typeof t === 'string');
|
|
48
|
+
if (typeof raw === 'string')
|
|
49
|
+
return [raw];
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build the knowledge graph from a set of already-loaded lat.md file
|
|
54
|
+
* contents. Used for both the working tree and a reconstructed git
|
|
55
|
+
* revision -- the only difference between the two call sites is where the
|
|
56
|
+
* file contents came from.
|
|
57
|
+
*
|
|
58
|
+
* `includeCode` controls whether the code-ref and source-wikilink halves of
|
|
59
|
+
* the graph are built by scanning the live project tree on disk. This is
|
|
60
|
+
* only meaningful for the working tree: a git revision's source tree is not
|
|
61
|
+
* reconstructed, only its lat.md/ documents are, so `includeCode` is false
|
|
62
|
+
* for `--at` / `--since`.
|
|
63
|
+
*/
|
|
64
|
+
export async function buildGraph(files, projectRoot, includeCode) {
|
|
65
|
+
const nodes = new Map();
|
|
66
|
+
const edges = [];
|
|
67
|
+
const addNode = (node) => {
|
|
68
|
+
if (!nodes.has(node.id))
|
|
69
|
+
nodes.set(node.id, node);
|
|
70
|
+
};
|
|
71
|
+
const addEdge = (edge) => {
|
|
72
|
+
edges.push(edge);
|
|
73
|
+
};
|
|
74
|
+
const ensureSectionNode = (id, label) => {
|
|
75
|
+
const sid = sectionId(id);
|
|
76
|
+
if (!nodes.has(sid)) {
|
|
77
|
+
addNode({
|
|
78
|
+
id: sid,
|
|
79
|
+
type: 'section',
|
|
80
|
+
label: cleanUntrustedId(label ?? id),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return sid;
|
|
84
|
+
};
|
|
85
|
+
const allSections = [];
|
|
86
|
+
const sortedFiles = [...files].sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
87
|
+
for (const f of sortedFiles) {
|
|
88
|
+
const absPath = join(projectRoot, f.relPath);
|
|
89
|
+
const sections = parseSections(absPath, f.content, projectRoot);
|
|
90
|
+
allSections.push(...sections);
|
|
91
|
+
const fm = parseFrontmatter(f.content).raw;
|
|
92
|
+
const provenance = readProvenance(f.content);
|
|
93
|
+
const mode = modeForFile(toPosix(f.relPath), fm);
|
|
94
|
+
const tags = tagsForFile(fm);
|
|
95
|
+
const dId = docId(f.relPath);
|
|
96
|
+
addNode({
|
|
97
|
+
id: dId,
|
|
98
|
+
type: 'document',
|
|
99
|
+
label: cleanUntrustedId(f.relPath),
|
|
100
|
+
file: f.relPath,
|
|
101
|
+
mode,
|
|
102
|
+
status: provenance.status,
|
|
103
|
+
reviewedHash: provenance.reviewedHash,
|
|
104
|
+
});
|
|
105
|
+
for (const tag of tags) {
|
|
106
|
+
const tId = tagId(tag);
|
|
107
|
+
addNode({ id: tId, type: 'tag', label: cleanUntrustedId(tag) });
|
|
108
|
+
addEdge({ from: dId, to: tId, type: 'tag' });
|
|
109
|
+
}
|
|
110
|
+
const walkSection = (section, parentId) => {
|
|
111
|
+
const sId = ensureSectionNode(section.id, section.heading);
|
|
112
|
+
const node = nodes.get(sId);
|
|
113
|
+
node.file = f.relPath;
|
|
114
|
+
addEdge({ from: parentId, to: sId, type: 'contains' });
|
|
115
|
+
for (const child of section.children) {
|
|
116
|
+
walkSection(child, sId);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
for (const root of sections) {
|
|
120
|
+
walkSection(root, dId);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const flat = [];
|
|
124
|
+
const flatten = (secs) => {
|
|
125
|
+
for (const s of secs) {
|
|
126
|
+
flat.push(s);
|
|
127
|
+
flatten(s.children);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
flatten(allSections);
|
|
131
|
+
const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
|
|
132
|
+
const fileIndex = buildFileIndex(allSections);
|
|
133
|
+
const slugIndex = buildSectionSlugIndex(allSections);
|
|
134
|
+
// Wiki links: section -> section (or section -> code target).
|
|
135
|
+
for (const f of sortedFiles) {
|
|
136
|
+
const absPath = join(projectRoot, f.relPath);
|
|
137
|
+
const refs = extractRefs(absPath, f.content, projectRoot);
|
|
138
|
+
for (const ref of refs) {
|
|
139
|
+
const { resolved, ambiguous } = resolveRef(ref.target, sectionIds, fileIndex, slugIndex);
|
|
140
|
+
const fromId = ensureSectionNode(ref.fromSection);
|
|
141
|
+
if (!ambiguous && sectionIds.has(resolved.toLowerCase())) {
|
|
142
|
+
const toId = ensureSectionNode(resolved);
|
|
143
|
+
addEdge({ from: fromId, to: toId, type: 'wikilink' });
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const cId = codeId(ref.target);
|
|
147
|
+
addNode({
|
|
148
|
+
id: cId,
|
|
149
|
+
type: 'code',
|
|
150
|
+
label: cleanUntrustedId(ref.target),
|
|
151
|
+
});
|
|
152
|
+
addEdge({ from: fromId, to: cId, type: 'wikilink' });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Code refs (the `// @lat: [[section-id]]` comment marker) and test-spec coverage: code -> section.
|
|
157
|
+
if (includeCode) {
|
|
158
|
+
const scan = await scanCodeRefs(projectRoot);
|
|
159
|
+
for (const ref of scan.refs) {
|
|
160
|
+
const { resolved, ambiguous } = resolveRef(ref.target, sectionIds, fileIndex, slugIndex);
|
|
161
|
+
const label = `${ref.file}:${ref.line}`;
|
|
162
|
+
const cId = codeId(label);
|
|
163
|
+
addNode({ id: cId, type: 'code', label: cleanUntrustedId(label) });
|
|
164
|
+
const toId = !ambiguous && sectionIds.has(resolved.toLowerCase())
|
|
165
|
+
? ensureSectionNode(resolved)
|
|
166
|
+
: ensureSectionNode(ref.target);
|
|
167
|
+
addEdge({ from: cId, to: toId, type: 'code-ref' });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { nodes: [...nodes.values()], edges };
|
|
171
|
+
}
|
|
172
|
+
/** Read the working tree's lat.md files straight off disk. */
|
|
173
|
+
export async function loadWorkingTreeFiles(latticeDir, projectRoot) {
|
|
174
|
+
const { listLatticeFiles } = await import('./lattice.js');
|
|
175
|
+
const { readFile } = await import('node:fs/promises');
|
|
176
|
+
const paths = await listLatticeFiles(latticeDir);
|
|
177
|
+
const files = [];
|
|
178
|
+
for (const p of paths) {
|
|
179
|
+
const content = await readFile(p, 'utf-8');
|
|
180
|
+
files.push({ relPath: toPosix(relative(projectRoot, p)), content });
|
|
181
|
+
}
|
|
182
|
+
return files;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Reconstruct the lat.md/ markdown tree as it existed at a git revision.
|
|
186
|
+
* Only the documents are reconstructed, not the surrounding source tree —
|
|
187
|
+
* `buildGraph` is called with `includeCode: false` for these.
|
|
188
|
+
*/
|
|
189
|
+
export async function loadRevisionFiles(projectRoot, latticeDir, rev) {
|
|
190
|
+
const latRelDir = toPosix(relative(projectRoot, latticeDir));
|
|
191
|
+
let lsOutput;
|
|
192
|
+
try {
|
|
193
|
+
const { stdout } = await execFileAsync('git', ['ls-tree', '-r', '--name-only', rev, '--', latRelDir], { cwd: projectRoot });
|
|
194
|
+
lsOutput = stdout;
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
throw new Error(`failed to list "${latRelDir}" at revision "${rev}": ${err instanceof Error ? err.message : String(err)}`);
|
|
198
|
+
}
|
|
199
|
+
const paths = lsOutput
|
|
200
|
+
.split('\n')
|
|
201
|
+
.map((l) => l.trim())
|
|
202
|
+
.filter((l) => l.endsWith('.md'));
|
|
203
|
+
const files = [];
|
|
204
|
+
for (const p of paths) {
|
|
205
|
+
const { stdout } = await execFileAsync('git', ['show', `${rev}:${p}`], {
|
|
206
|
+
cwd: projectRoot,
|
|
207
|
+
});
|
|
208
|
+
files.push({ relPath: p, content: stdout });
|
|
209
|
+
}
|
|
210
|
+
return files;
|
|
211
|
+
}
|
|
212
|
+
/** Diff two graphs' section nodes by id, reporting added/removed/changed. */
|
|
213
|
+
export function diffGraphs(before, after) {
|
|
214
|
+
const beforeSections = new Map(before.nodes.filter((n) => n.type === 'section').map((n) => [n.id, n]));
|
|
215
|
+
const afterSections = new Map(after.nodes.filter((n) => n.type === 'section').map((n) => [n.id, n]));
|
|
216
|
+
const added = [];
|
|
217
|
+
const removed = [];
|
|
218
|
+
const changed = [];
|
|
219
|
+
for (const [id, node] of afterSections) {
|
|
220
|
+
const prior = beforeSections.get(id);
|
|
221
|
+
if (!prior) {
|
|
222
|
+
added.push(id);
|
|
223
|
+
}
|
|
224
|
+
else if (prior.label !== node.label) {
|
|
225
|
+
changed.push(id);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
for (const id of beforeSections.keys()) {
|
|
229
|
+
if (!afterSections.has(id))
|
|
230
|
+
removed.push(id);
|
|
231
|
+
}
|
|
232
|
+
return { added, removed, changed };
|
|
233
|
+
}
|
|
234
|
+
// --- Formatters -----------------------------------------------------------
|
|
235
|
+
export function formatGraphJson(graph) {
|
|
236
|
+
return JSON.stringify(graph, null, 2);
|
|
237
|
+
}
|
|
238
|
+
const NODE_SHAPE = {
|
|
239
|
+
document: ['([', '])'],
|
|
240
|
+
section: ['[', ']'],
|
|
241
|
+
code: ['[[', ']]'],
|
|
242
|
+
tag: ['((', '))'],
|
|
243
|
+
};
|
|
244
|
+
function mermaidEscape(label) {
|
|
245
|
+
return label.replace(/"/g, '#quot;');
|
|
246
|
+
}
|
|
247
|
+
export function formatGraphMermaid(graph) {
|
|
248
|
+
const idMap = new Map();
|
|
249
|
+
graph.nodes.forEach((n, i) => idMap.set(n.id, `n${i}`));
|
|
250
|
+
const lines = ['flowchart TD'];
|
|
251
|
+
for (const node of graph.nodes) {
|
|
252
|
+
const [open, close] = NODE_SHAPE[node.type];
|
|
253
|
+
const label = mermaidEscape(`${node.type}: ${node.label}`);
|
|
254
|
+
lines.push(` ${idMap.get(node.id)}${open}"${label}"${close}`);
|
|
255
|
+
}
|
|
256
|
+
for (const edge of graph.edges) {
|
|
257
|
+
const from = idMap.get(edge.from);
|
|
258
|
+
const to = idMap.get(edge.to);
|
|
259
|
+
if (!from || !to)
|
|
260
|
+
continue;
|
|
261
|
+
lines.push(` ${from} -->|${edge.type}| ${to}`);
|
|
262
|
+
}
|
|
263
|
+
return lines.join('\n');
|
|
264
|
+
}
|
|
265
|
+
function dotEscape(label) {
|
|
266
|
+
return label.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
267
|
+
}
|
|
268
|
+
export function formatGraphDot(graph) {
|
|
269
|
+
const idMap = new Map();
|
|
270
|
+
graph.nodes.forEach((n, i) => idMap.set(n.id, `n${i}`));
|
|
271
|
+
const lines = ['digraph lat {'];
|
|
272
|
+
for (const node of graph.nodes) {
|
|
273
|
+
const label = dotEscape(`${node.type}: ${node.label}`);
|
|
274
|
+
lines.push(` ${idMap.get(node.id)} [label="${label}"];`);
|
|
275
|
+
}
|
|
276
|
+
for (const edge of graph.edges) {
|
|
277
|
+
const from = idMap.get(edge.from);
|
|
278
|
+
const to = idMap.get(edge.to);
|
|
279
|
+
if (!from || !to)
|
|
280
|
+
continue;
|
|
281
|
+
lines.push(` ${from} -> ${to} [label="${dotEscape(edge.type)}"];`);
|
|
282
|
+
}
|
|
283
|
+
lines.push('}');
|
|
284
|
+
return lines.join('\n');
|
|
285
|
+
}
|
|
286
|
+
export function formatGraph(graph, format) {
|
|
287
|
+
switch (format) {
|
|
288
|
+
case 'mermaid':
|
|
289
|
+
return formatGraphMermaid(graph);
|
|
290
|
+
case 'dot':
|
|
291
|
+
return formatGraphDot(graph);
|
|
292
|
+
case 'json':
|
|
293
|
+
default:
|
|
294
|
+
return formatGraphJson(graph);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
export function formatGraphDiff(diff) {
|
|
298
|
+
const lines = [];
|
|
299
|
+
lines.push(`added (${diff.added.length}):`);
|
|
300
|
+
for (const id of diff.added)
|
|
301
|
+
lines.push(` + ${cleanUntrustedId(id)}`);
|
|
302
|
+
lines.push(`removed (${diff.removed.length}):`);
|
|
303
|
+
for (const id of diff.removed)
|
|
304
|
+
lines.push(` - ${cleanUntrustedId(id)}`);
|
|
305
|
+
lines.push(`changed (${diff.changed.length}):`);
|
|
306
|
+
for (const id of diff.changed)
|
|
307
|
+
lines.push(` ~ ${cleanUntrustedId(id)}`);
|
|
308
|
+
return lines.join('\n');
|
|
309
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bump this number whenever `lat init` setup or defaults change in a way that
|
|
3
|
+
* requires users to re-run it (e.g. new hooks, generated files, MCP config, or
|
|
4
|
+
* a configuration migration).
|
|
5
|
+
*/
|
|
6
|
+
export declare const INIT_VERSION = 5;
|
|
7
|
+
export declare function readInitVersion(latDir: string): number | null;
|
|
8
|
+
export declare function readFileHash(latDir: string, relPath: string): string | null;
|
|
9
|
+
export declare function contentHash(content: string): string;
|
|
10
|
+
export declare function writeInitMeta(latDir: string, fileHashes: Record<string, string>): void;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Bump this number whenever `lat init` setup or defaults change in a way that
|
|
6
|
+
* requires users to re-run it (e.g. new hooks, generated files, MCP config, or
|
|
7
|
+
* a configuration migration).
|
|
8
|
+
*/
|
|
9
|
+
export const INIT_VERSION = 5;
|
|
10
|
+
function cachePath(latDir) {
|
|
11
|
+
return join(latDir, '.cache', 'lat_init.json');
|
|
12
|
+
}
|
|
13
|
+
function readMeta(latDir) {
|
|
14
|
+
const p = cachePath(latDir);
|
|
15
|
+
if (!existsSync(p))
|
|
16
|
+
return null;
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function readInitVersion(latDir) {
|
|
25
|
+
const meta = readMeta(latDir);
|
|
26
|
+
if (!meta)
|
|
27
|
+
return null;
|
|
28
|
+
return typeof meta.init_version === 'number' ? meta.init_version : null;
|
|
29
|
+
}
|
|
30
|
+
export function readFileHash(latDir, relPath) {
|
|
31
|
+
const meta = readMeta(latDir);
|
|
32
|
+
return meta?.file_hashes?.[relPath] ?? null;
|
|
33
|
+
}
|
|
34
|
+
export function contentHash(content) {
|
|
35
|
+
return createHash('sha256').update(content).digest('hex');
|
|
36
|
+
}
|
|
37
|
+
export function writeInitMeta(latDir, fileHashes) {
|
|
38
|
+
const cacheDir = join(latDir, '.cache');
|
|
39
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
40
|
+
// Merge with existing hashes so we don't lose entries from agents
|
|
41
|
+
// that weren't selected this run
|
|
42
|
+
const existing = readMeta(latDir);
|
|
43
|
+
const mergedHashes = { ...existing?.file_hashes, ...fileHashes };
|
|
44
|
+
const data = {
|
|
45
|
+
init_version: INIT_VERSION,
|
|
46
|
+
file_hashes: mergedHashes,
|
|
47
|
+
};
|
|
48
|
+
writeFileSync(cachePath(latDir), JSON.stringify(data, null, 2) + '\n');
|
|
49
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { execFile, } from 'node:child_process';
|
|
2
|
+
const CALL_TIMEOUT_MS = 2000;
|
|
3
|
+
/**
|
|
4
|
+
* Run `bd memories --json <term>` for a single term. Never throws: a missing
|
|
5
|
+
* binary (ENOENT), non-zero exit, timeout, or unparseable JSON all collapse
|
|
6
|
+
* to "no matches for this term" so one bad term can't sink the whole query.
|
|
7
|
+
*
|
|
8
|
+
* Uses the async `execFile` (not `execFileSync`) so this call never blocks
|
|
9
|
+
* the event loop: `federateTags` fans out to all three stores with
|
|
10
|
+
* `Promise.allSettled`, and that fan-out only buys real concurrency if each
|
|
11
|
+
* store's I/O actually yields control while its subprocess runs.
|
|
12
|
+
*/
|
|
13
|
+
function queryTerm(term) {
|
|
14
|
+
return new Promise((resolvePromise) => {
|
|
15
|
+
// argv form, no shell: terms come from repository frontmatter and are
|
|
16
|
+
// attacker-controlled when `lat` runs in a repo we don't own, so a term
|
|
17
|
+
// must never be interpretable as shell syntax.
|
|
18
|
+
// stdin: 'ignore' is load-bearing, not cosmetic. The shell version this
|
|
19
|
+
// replaced ran its per-term loop inside a `while read` on stdin; an
|
|
20
|
+
// external tool in that loop that itself read stdin would silently
|
|
21
|
+
// consume the remaining terms, and the symptom was some documents just
|
|
22
|
+
// never showing up — indistinguishable from "no match". Detaching stdin
|
|
23
|
+
// here removes that whole class of bug. `stdio` isn't in the string-
|
|
24
|
+
// encoding overload's declared option type even though Node accepts it
|
|
25
|
+
// at runtime, hence the explicit cast.
|
|
26
|
+
const options = {
|
|
27
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
28
|
+
timeout: CALL_TIMEOUT_MS,
|
|
29
|
+
encoding: 'utf-8',
|
|
30
|
+
};
|
|
31
|
+
execFile('bd', ['memories', '--json', term], options, (error, stdout) => {
|
|
32
|
+
// execFile's async form reports a non-zero exit and a timeout kill
|
|
33
|
+
// both through `error`, and never rejects — so every failure mode
|
|
34
|
+
// is handled right here rather than via a catch on the returned
|
|
35
|
+
// promise. Match `queryTerm`'s old spawnSync contract of "empty
|
|
36
|
+
// object" for anything that isn't a clean, parseable JSON object.
|
|
37
|
+
if (error || !stdout) {
|
|
38
|
+
resolvePromise({});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(stdout);
|
|
43
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
44
|
+
resolvePromise(parsed);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
resolvePromise({});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
resolvePromise({});
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function query(q) {
|
|
57
|
+
try {
|
|
58
|
+
return await rank(q);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// The store contract is that a lookup never fails the caller's prompt.
|
|
62
|
+
// queryTerm already absorbs a failing `bd`, but the surrounding logic
|
|
63
|
+
// could still throw on an unexpected input, so the guarantee is made
|
|
64
|
+
// unconditional here.
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function rank(q) {
|
|
69
|
+
// key -> { content, count }, built in first-seen order so a stable sort
|
|
70
|
+
// below keeps ties in the author's original term order rather than an
|
|
71
|
+
// alphabetical accident.
|
|
72
|
+
const seen = new Map();
|
|
73
|
+
// Terms belong to the same document, so their subprocess calls are
|
|
74
|
+
// independent of each other — run them concurrently rather than one at a
|
|
75
|
+
// time. Each individual call still enforces CALL_TIMEOUT_MS; running them
|
|
76
|
+
// together bounds the whole document's bd lookup by that same timeout
|
|
77
|
+
// instead of `terms.length * CALL_TIMEOUT_MS`.
|
|
78
|
+
const perTerm = await Promise.all(q.terms.map((term) => queryTerm(term)));
|
|
79
|
+
for (const memories of perTerm) {
|
|
80
|
+
for (const [key, content] of Object.entries(memories)) {
|
|
81
|
+
if (!key || typeof content !== 'string' || content === '')
|
|
82
|
+
continue;
|
|
83
|
+
const existing = seen.get(key);
|
|
84
|
+
if (existing) {
|
|
85
|
+
existing.count += 1;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
seen.set(key, { content, count: 1 });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const hits = [...seen.entries()].map(([key, v]) => ({
|
|
93
|
+
store: 'bd',
|
|
94
|
+
key,
|
|
95
|
+
title: key,
|
|
96
|
+
detail: v.content.split('\n')[0] ?? '',
|
|
97
|
+
score: v.count,
|
|
98
|
+
}));
|
|
99
|
+
// Array.prototype.sort is stable, so entries with equal score retain the
|
|
100
|
+
// first-seen (author's term) order from the map iteration above.
|
|
101
|
+
hits.sort((a, b) => b.score - a.score);
|
|
102
|
+
return hits.slice(0, q.limit);
|
|
103
|
+
}
|
|
104
|
+
export const bdStore = {
|
|
105
|
+
name: 'bd',
|
|
106
|
+
query,
|
|
107
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Store } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Match Claude Code's own project-directory slug: every character that
|
|
4
|
+
* isn't a letter or digit becomes `-`, including `.` — a project root like
|
|
5
|
+
* `/Users/dave/projects/claude-code/lat.md` slugifies to
|
|
6
|
+
* `-Users-dave-projects-claude-code-lat-md`, verified against a real
|
|
7
|
+
* `~/.claude/projects/` listing. Replacing only `/` (the previous
|
|
8
|
+
* implementation) leaves dots in the slug and never matches the real
|
|
9
|
+
* directory, so the store silently finds nothing for any project root
|
|
10
|
+
* containing a dot.
|
|
11
|
+
*/
|
|
12
|
+
export declare function slugify(absPath: string): string;
|
|
13
|
+
export declare const claudeMemoryStore: Store;
|