From 9234f8ef2b85be1000a56035c3ed0c553111d064 Mon Sep 17 00:00:00 2001 From: Elvis Pranskevichus Date: Fri, 17 Apr 2026 13:23:15 -0700 Subject: [PATCH] Add external source links with local overrides This lets lat.md deep-link into pinned external repositories while keeping machine-local checkouts, validation, init setup, and agent guidance aligned. Expose handle-level lookup in the CLI and MCP so agents can resolve configured externals before reading them. Teach generated integrations and skills to use it for prompts like 'read for '. --- .gitignore | 1 + README.md | 39 +- lat.md/.gitignore | 1 + lat.md/cli.md | 37 +- lat.md/markdown.md | 53 ++ lat.md/tests/external-sources.md | 64 +++ lat.md/tests/mcp.md | 6 +- lat.md/tests/tests.md | 1 + package.json | 1 + pnpm-lock.yaml | 33 +- src/cli/check.ts | 52 +- src/cli/expand.ts | 70 ++- src/cli/get-source.ts | 35 ++ src/cli/index.ts | 38 +- src/cli/init.ts | 182 +++++++ src/cli/refs.ts | 73 +++ src/cli/section.ts | 47 +- src/external-sources.ts | 526 ++++++++++++++++++++ src/lattice.ts | 40 +- src/mcp/server.ts | 13 + templates/init/.gitignore | 1 + templates/opencode-plugin.ts | 17 + templates/pi-extension.ts | 26 + templates/skill/SKILL.md | 97 +++- tests/cases/external-project/lat.md/docs.md | 5 + tests/cases/external-project/lat.md/lat.md | 14 + tests/external-sources.test.ts | 411 +++++++++++++++ tests/mcp.test.ts | 41 +- 28 files changed, 1865 insertions(+), 59 deletions(-) create mode 100644 lat.md/tests/external-sources.md create mode 100644 src/cli/get-source.ts create mode 100644 src/external-sources.ts create mode 100644 tests/cases/external-project/lat.md/docs.md create mode 100644 tests/cases/external-project/lat.md/lat.md create mode 100644 tests/external-sources.test.ts diff --git a/.gitignore b/.gitignore index 8135818..1ce6dac 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ dist .codex .cursor/hooks.json .cursor +opencode.json diff --git a/README.md b/README.md index a7711e3..b76ffd5 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,11 @@ ## The idea -Compress the knowledge about your program domain into a **graph** — a set of interconnected markdown files that live in a `lat.md/` directory at the root of your project. Sections link to each other with `[[wiki links]]`, markdown files link into the codebase (`[[src/auth.ts#validateToken]]`), source files link back with `// @lat: [[section-id]]` comments, and `lat check` ensures nothing drifts out of sync. +Compress the knowledge about your program domain into a **graph** — a set of interconnected markdown files that live in a `lat.md/` directory at the root of your project. Sections link to each other with `[[wiki links]]`, markdown files link into the codebase (`[[src/auth.ts#validateToken]]`) or pinned external sources (`[[architecture-docs:docs/system/request-flow.md#L123]]`), source files link back with `// @lat: [[section-id]]` comments, and `lat check` ensures nothing drifts out of sync. - **Faster coding for agents** — instead of grepping through your codebase, agents search the knowledge graph to discover key design decisions, constraints, and domain context fast and consistently. -- **Faster workflow for humans** — your agents maintain lat files for you. When you review a diff, start with the semantic changes in `lat.md/` to understand *what* changed and *why*. Reviewing code becomes the secondary task. +- **Faster workflow for humans** — your agents maintain lat files for you. When you review a diff, start with the semantic changes in `lat.md/` to understand _what_ changed and _why_. Reviewing code becomes the secondary task. - **Knowledge retention** — the context and reasoning behind your prompts is usually lost after a session ends. With lat, agents capture that knowledge into the graph as they work, so future sessions start with full context instead of rediscovering it from scratch. @@ -43,7 +43,7 @@ Then run `lat init` in the repo you want to use lat in. ## How it works -Run `lat init` to scaffold a `lat.md/` directory, then write markdown files describing your architecture, business logic, test specs — whatever matters. Link between sections using `[[file#Section#Subsection]]` syntax. Link to source code symbols with `[[src/auth.ts#validateToken]]`. Annotate source code with `// @lat: [[section-id]]` (or `# @lat: [[section-id]]` in Python) comments to tie implementation back to concepts. +Run `lat init` to scaffold a `lat.md/` directory, then write markdown files describing your architecture, business logic, test specs — whatever matters. Link between sections using `[[file#Section#Subsection]]` syntax. Link to source code symbols with `[[src/auth.ts#validateToken]]`. You can also define external source handles in `lat.md/lat.md` frontmatter and link to them with `[[handle:path#fragment]]`. Annotate source code with `// @lat: [[section-id]]` (or `# @lat: [[section-id]]` in Python) comments to tie implementation back to concepts. ``` my-project/ @@ -65,6 +65,7 @@ lat check # validate all wiki links and code refs lat locate "OAuth Flow" # find sections by name (exact, fuzzy) lat section "auth#OAuth Flow" # show a section with its links and refs lat refs "auth#OAuth Flow" # find what references a section +lat get-source architecture-docs # show repo URL or local checkout for a handle lat search "how do we auth?" # semantic search via embeddings lat expand "fix [[OAuth Flow]]" # expand [[refs]] in a prompt for agents lat mcp # start MCP server for editor integration @@ -79,6 +80,38 @@ Semantic search (`lat search`) requires an OpenAI (`sk-...`) or Vercel AI Gatewa 3. `LAT_LLM_KEY_HELPER` env var — shell command that prints the key (10s timeout) 4. Config file — saved by `lat init`. Run `lat config` to see its location. +External source handles are configured per project: + +- Canonical handle definitions live in `lat.md/lat.md` frontmatter under `lat.external-sources` +- Machine-local checkout overrides live in `lat.md/config.local.json` under the same `lat.external-sources` key path, using `path` + +Example: + +```yaml +--- +lat: + external-sources: + architecture-docs: + repo: https://example.com/architecture-docs.git + rev: v6.9 + browse: https://example.com/architecture-docs/tree/{path}?h={rev}#{fragment} +--- +``` + +```json +{ + "lat": { + "external-sources": { + "architecture-docs": { + "path": "~/src/architecture-docs" + } + } + } +} +``` + +Local override `path` values may start with `~/`; `lat` expands that to the current user's home directory before validating or navigating the checkout. + ## Development Requires Node.js 22+ and pnpm. diff --git a/lat.md/.gitignore b/lat.md/.gitignore index cc8008f..d12566b 100644 --- a/lat.md/.gitignore +++ b/lat.md/.gitignore @@ -1,3 +1,4 @@ .obsidian .cache *.canvas +config.local.json diff --git a/lat.md/cli.md b/lat.md/cli.md index be0e157..1d99b82 100644 --- a/lat.md/cli.md +++ b/lat.md/cli.md @@ -30,7 +30,7 @@ Output: 1. Section header with id and file location 2. Section content blockquoted (`>`) from `startLine` through the end of the last descendant subsection -3. **This section references** — all wiki link targets found within the section, including both lat.md section refs (with body descriptions) and source code refs (with file path and line range, e.g. `file.ts:10-25`, plus a 5-line snippet centered on the symbol) +3. **This section references** — all wiki link targets found within the section, including lat.md section refs (with body descriptions), source code refs (with file path and line range, e.g. `file.ts:10-25`, plus a 5-line snippet centered on the symbol), and external source refs (with the active local or canonical destination, plus resolved line ranges when a local checkout can map a line fragment or AsciiDoc heading fragment) 4. **Referenced by** — other sections in `lat.md/` that contain wiki links pointing to this section 5. **Referenced by code** — source files containing `@lat:` comments that reference this section, each shown with file path, line number, and a 5-line snippet centered on the reference 6. **Navigation hints** — same footer as [[cli#search]], suggesting `lat section` and `lat search` as next steps @@ -41,12 +41,14 @@ Core logic in [[src/cli/section.ts#getSection]] (returns structured result), use ## refs -Find sections that reference a given target via [[parser#Wiki Links]]. The query can be a section id or a source file path. +Find sections that reference a given target via [[parser#Wiki Links]]. The query can be a section id, a source file path, or an exact external source target. **Section queries** (e.g. `section-parsing#Heading`) are resolved via `findSections` when `resolveRef` doesn't produce an exact match, as long as the result is unambiguous (exact, stem-expanded, or section-name match). If no confident match exists, shows "Did you mean:" suggestions and exits. **Source file queries** (e.g. `src/app.rs#greet`, `src/app.ts`) are detected when the file part has a recognized source extension and exists on disk. File-level queries (no `#`) match all wiki links targeting that file or any symbol in it. Symbol-level queries match exactly. +**External source queries** (e.g. `architecture-docs:docs/system/request-flow.md#L123`) are matched exactly against configured external targets from [[markdown#Frontmatter#external-sources]]. They return markdown sections that reference that exact handle/path/fragment. + Outputs a [[cli#Section Preview]] for each referring section. Usage: `lat refs [--scope=md|code|md+code]` @@ -59,11 +61,21 @@ Usage: `lat refs [--scope=md|code|md+code]` Core logic in [[src/cli/refs.ts#findRefs]] (returns structured result), used by both the CLI command and [[cli#mcp]] `lat_refs` tool. +## get-source + +Resolve a configured external source handle to its active location, preferring a valid local checkout path and otherwise falling back to the canonical repository URL. + +Usage: `lat get-source ` + +This is for handle-level lookup, not file-level deep links. For example, `lat get-source architecture-docs` returns either the pinned local checkout root from `lat.md/config.local.json` or the canonical `repo` URL from `lat.md/lat.md` frontmatter. + +Implementation: [[src/cli/get-source.ts#getSourceCommand]] + ## check Validation command group. Runs all checks when invoked without a subcommand. -Usage: `lat check [md|code-refs|index|sections]` +Usage: `lat check [md|code-refs|index|sections] [--ignore-local-overrides]` Emits a stale-init warning before any errors so the user sees setup issues first. The init version check compares `INIT_VERSION` in [[src/init-version.ts]] against the version in `lat.md/.cache/lat_init.json` written by [[cli#init]]. Missing LLM key warning appears only when all checks pass. If the total check took longer than one second and ripgrep is not installed, shows a tip suggesting the user install it for faster scanning. The first output line ("Scanned ...") includes the total elapsed time (e.g. "in 250ms" or "in 1.2s"). @@ -73,6 +85,10 @@ Implementation: [[src/cli/check.ts]] Validate that all [[parser#Wiki Links]] in `lat.md` markdown files point to existing sections. +Configured external refs in `[[handle:path#fragment]]` form are also accepted here. `check md` validates the handle definition, required canonical fields (`repo`, `rev`, `browse`), and any local override path from `lat.md/config.local.json`. + +`--ignore-local-overrides` treats `lat.md/config.local.json` as absent for that check run. Use it when you want a portable check that validates only checked-in canonical config and ignores machine-specific local override errors. + ### code-refs Two validations: @@ -80,6 +96,8 @@ Two validations: 1. Every `// @lat: [[...]]` or `# @lat: [[...]]` comment in source code must point to a real section in `lat.md/` 2. For files with [[markdown#Frontmatter#require-code-mention]], every leaf section must be referenced by at least one `// @lat:` comment in the codebase +Configured external refs in `[[handle:path#fragment]]` form are also accepted here. `check code-refs` treats them as valid when the handle exists in `lat.external-sources`. + ### sections Validate that every section has a well-formed leading paragraph. Two checks: @@ -97,7 +115,7 @@ Each index file must contain a bullet list covering every visible file and subdi Four checks: -1. **Non-markdown files** — any file without a `.md` extension is flagged as an error (only markdown belongs in `lat.md/`) +1. **Non-markdown files** — any file without a `.md` extension is flagged as an error (only markdown belongs in `lat.md/`), except for `lat.md/config.local.json` 2. **Missing index file** — errors with a ready-to-copy bullet list snippet 3. **Missing entries** — index file exists but doesn't list all visible entries 4. **Stale entries** — index file lists an entry that doesn't exist on disk @@ -117,6 +135,8 @@ For each `[[ref]]` in the input, uses `findSections()` directly (no `resolveRef` 1. **Best match** — resolves to the top result from `findSections` (exact > file stem > subsection > subsequence > fuzzy) 2. **No match** — errors out, tells the agent to ask the user to correct the reference +Configured external refs are not rewritten inline. Instead, `lat expand` keeps `[[handle:path#fragment]]` as authored and appends context describing the external handle, pinned revision, and active local or canonical destination. + Output replaces `[[ref]]` with `[[resolved-id]]` inline and appends a `` block as a nested outliner. For exact matches: `is referring to:`. For non-exact: `might be referring to either of the following:` with all candidates, match reasons, locations, and body text. Implementation: [[src/cli/expand.ts]] @@ -147,7 +167,7 @@ Usage: `lat init [dir]` Steps: -1. **lat.md/ directory** — if not present, asks whether to create it (via a one-off readline interface that is closed before step 2). Scaffolds from `templates/init/` (`.gitignore` and `README.md`). If it already exists, skips ahead. +1. **lat.md/ directory** — if not present, asks whether to create it (via a one-off readline interface that is closed before step 2). Scaffolds from `templates/init/` (`.gitignore` and `README.md`). If it already exists, skips ahead. Re-runs also ensure `lat.md/.gitignore` includes `config.local.json` for local external source overrides. 2. **Agent selection** — interactive checklist menu ([[src/cli/checklist-menu.ts#checklistMenu]]). All agents are shown at once with `[x]`/`[ ]` checkboxes; the cursor row is highlighted with `chalk.bgCyan`. Keys: up/down (j/k) to move, Space to toggle, Enter to confirm, Ctrl+C to abort. Returns an array of selected agent values. Non-TTY fallback returns `[]`. After confirmation, prints a summary line (e.g. "Selected: Claude Code, Cursor" or dim "None"). **Important:** the persistent readline interface is created _after_ this step — `checklistMenu` puts stdin into raw mode with its own `data` listener, which corrupts any co-existing readline interface. 3. **Command style** — if any selected agent needs a lat command reference (all except Codex), a `selectMenu` asks "How should agents run lat?" with three options: `lat` (global install, portable), the resolved local binary path, or `npx lat.md@latest` (slow but zero-install). The choice determines what command string is written into hooks, MCP configs, and Pi extensions. Non-interactive mode defaults to `local`. Choosing `global` or `npx` makes generated config files portable and safe to commit. 4. **AGENTS.md** — created if a non-Claude agent is selected (Cursor, Copilot, Codex). Shared instruction file. Uses marker-based append mode (see below). @@ -168,6 +188,7 @@ Sets up `CLAUDE.md` and two agent hooks for the Claude Code coding agent. - Hooks synced in `.claude/settings.json` — on every run, all existing lat-owned hook entries are removed, then fresh entries are added for both events. Detection uses three heuristics: `/\blat\b/` in the command string, `hook claude ` substring (catches any install path), or command starting with the current binary path. Non-lat hooks are preserved. Both hooks call [[cli#hook]]: - `UserPromptSubmit` → `lat hook claude UserPromptSubmit` — injects lat.md workflow reminders, auto-resolves `[[refs]]` in the prompt - `Stop` → `lat hook claude Stop` — reminds the agent to update `lat.md/` before finishing +- `.claude/settings.local.json` — if `lat.md/config.local.json` defines external source paths, `lat init` adds them to Claude's `additionalDirectories` list so the local checkout is readable without extra approval prompts - `.claude/skills/lat-md/SKILL.md` — skill spec generated from `templates/skill/SKILL.md`. Teaches the agent how to author and maintain `lat.md/` files. Claude Code discovers it automatically from `.claude/skills/`. - `.claude` directory added to `.gitignore` (settings contain local absolute paths in hook commands) - [[cli#mcp]] server registered in `.mcp.json` at the project root (added to `.gitignore` since it contains absolute paths) @@ -206,6 +227,7 @@ Sets up an OpenCode plugin that registers lat tools as native OpenCode tools and - `AGENTS.md` — shared instruction file (created in the shared step) - `.opencode/plugins/lat.ts` — TypeScript plugin generated from `templates/opencode-plugin.ts` with the lat invocation command injected. Uses `@opencode-ai/plugin` to register six tools (`lat_search`, `lat_section`, `lat_locate`, `lat_check`, `lat_expand`, `lat_refs`) that shell out to the `lat` CLI. Hooks into `session.idle` (runs `lat check` + diff analysis, logs a warning via `client.app.log` if something needs fixing). +- `opencode.json` — if `lat.md/config.local.json` defines external source paths, `lat init` can add `permission.external_directory` allow rules for those checkouts. When `opencode.json` does not yet exist, init first adds it to the root `.gitignore` before writing local-only permissions. If the file already exists and is not gitignored, init leaves it untouched. - `.agents/skills/lat-md/SKILL.md` — skill spec for authoring `lat.md/` files, placed in the cross-agent standard skills directory - `.opencode` directory added to `.gitignore` (plugin contains local absolute paths) @@ -239,6 +261,8 @@ Currently supports one field: - `llm_key` — embedding API key for semantic search, used when `LAT_LLM_KEY` env var is not set +Project-local external source overrides live separately in `lat.md/config.local.json`. Run `lat config` to see both the user config path and, when inside a project, the project-local config path. + Key resolution order: `LAT_LLM_KEY` > `LAT_LLM_KEY_FILE` > `LAT_LLM_KEY_HELPER` > config file `llm_key`. This applies everywhere: `lat search`, `lat check`, and the MCP `lat_search` tool. Implementation: [[src/config.ts]] @@ -285,7 +309,7 @@ Start the MCP (Model Context Protocol) server over stdio. Exposes lat.md tools t Usage: `lat mcp` -Clients invoke this as `lat mcp`. The `lat init` wizard registers the MCP server using the absolute path to the current `lat` binary, so it works regardless of how `lat` was installed. The server exposes six tools: +Clients invoke this as `lat mcp`. The `lat init` wizard registers the MCP server using the absolute path to the current `lat` binary, so it works regardless of how `lat` was installed. The server exposes seven tools: - **lat_locate** — find sections by name (wraps [[cli#locate]]) - **lat_section** — show section content with outgoing/incoming refs (wraps [[cli#section]]) @@ -293,6 +317,7 @@ Clients invoke this as `lat mcp`. The `lat init` wizard registers the MCP server - **lat_expand** — expand `[[refs]]` in text (wraps [[cli#expand]]) - **lat_check** — validate links and code refs (wraps [[cli#check]]) - **lat_refs** — find references to a section (wraps [[cli#refs]]) +- **lat_get_source** — resolve an external source handle to its active location (wraps [[cli#get-source]]) Each MCP tool calls the same command function as the CLI (e.g. `locateCommand`, `refsCommand`, `searchCommand`), passing a `CmdContext` with `plainStyler` and `mode: 'mcp'`. The `toMcp()` helper converts `CmdResult` to MCP response format. Uses `@modelcontextprotocol/sdk` with stdio transport. Resolves `lat.md/` from cwd. diff --git a/lat.md/markdown.md b/lat.md/markdown.md index bdb21b1..5bef2d4 100644 --- a/lat.md/markdown.md +++ b/lat.md/markdown.md @@ -54,6 +54,22 @@ C symbols: functions (including pointer-returning like `char *func()`), structs, Source code is parsed lazily with tree-sitter (via `web-tree-sitter`). Only files referenced by wiki links are parsed — no up-front scanning. [[cli#check#md]] validates that the file exists and the symbol is defined. +### External Source Links + +Wiki links can reference pinned external repositories through short handles defined in frontmatter. + +Use the syntax `[[handle:path/to/file#fragment]]`, for example `[[architecture-docs:docs/system/request-flow.md#L123]]`. The handle is resolved through `lat.external-sources` in `lat.md/lat.md` frontmatter, which defines the canonical repository URL, pinned revision, and browse URL template. + +Machine-local overrides live in `lat.md/config.local.json` under the same `lat.external-sources` key path. A local override provides `path`, pointing to a checkout of the same repository at the pinned revision. When present and valid, navigation prefers the local checkout; otherwise it falls back to the canonical `browse` URL. + +The same `[[handle:path#fragment]]` form is also valid inside source code `@lat:` comments when implementation or tests need to point directly at an external design document. + +For AsciiDoc files (`.adoc`, `.asciidoc`), fragments can target section headings using either autogenerated Asciidoctor ids like `#_extended_attributes` or explicit section ids like `#custom-layout`. When a valid local checkout is configured, lat resolves those heading fragments to local line ranges for display. + +Use [[cli#get-source]] or the `lat_get_source` MCP tool when you need the active root location for a handle itself rather than a deep-linked file target. + +External links are validated by [[cli#check#md]], and configured handles are also accepted by [[cli#check#code-refs]] when they appear in `@lat:` comments. If a local override is configured, `lat check` also verifies that the checkout exists, is a git repository, and that `HEAD` matches the configured pinned revision. + ### Strict vs Lenient Contexts **Strict** — `lat check` and `lat refs` use `resolveRef()` directly. Links must resolve unambiguously to a known section. Ambiguous or broken links are errors. @@ -82,3 +98,40 @@ lat: ### require-code-mention When set to `true`, [[cli#check#code-refs]] ensures every leaf section (sections with no children) in the file has a corresponding `// @lat: [[...]]` reference in source code. Useful for test specs and requirements that must be traceable to implementation. + +### external-sources + +The root `lat.md/lat.md` file can define canonical external source handles for deep-links into pinned external repositories. + +```yaml +--- +lat: + external-sources: + architecture-docs: + repo: https://example.com/architecture-docs.git + rev: v6.9 + browse: https://example.com/architecture-docs/tree/{path}?h={rev}#{fragment} +--- +``` + +The supported fields are: + +- `repo` — canonical repository identifier +- `rev` — pinned git revision used for validation and browse URLs +- `browse` — URL template with `{path}`, `{rev}`, and `{fragment}` substitutions + +Local machine overrides belong in `lat.md/config.local.json`: + +```json +{ + "lat": { + "external-sources": { + "architecture-docs": { + "path": "~/src/architecture-docs" + } + } + } +} +``` + +Only `path` is supported in `config.local.json`. A leading `~/` is expanded to the current user's home directory. This file is meant to stay local and should be gitignored. diff --git a/lat.md/tests/external-sources.md b/lat.md/tests/external-sources.md new file mode 100644 index 0000000..7f6cc01 --- /dev/null +++ b/lat.md/tests/external-sources.md @@ -0,0 +1,64 @@ +--- +lat: + require-code-mention: true +--- + +# External Sources + +Tests for external source handles that deep-link from `lat.md` and source code comments into pinned external repositories. + +## Check md accepts configured external refs + +When a wiki link uses a configured external handle like `[[architecture-docs:path/to/file#L123]]`, `check md` accepts it instead of treating it as a broken section link. + +## Check code-refs accepts configured external refs + +When a source code `@lat:` comment uses a configured external handle like `[[architecture-docs:path/to/file#L123]]`, `check code-refs` accepts it instead of requiring a `lat.md` section id. + +## Index allows config.local.json + +`check index` allows `lat.md/config.local.json` as the one supported non-markdown file in `lat.md/` and excludes it from directory listing requirements. + +## Refs finds markdown backlinks for external target + +`lat refs` accepts an exact external target query and returns markdown sections that reference that external handle/path combination. + +## Refs finds code backlinks for external target + +`lat refs --scope code` accepts an exact external target query and returns source locations whose `@lat:` comments reference that external handle/path combination. + +## Section output renders external destination + +`formatSectionOutput` shows external refs with an active destination, preferring a local pinned checkout when configured and valid. + +## Section output resolves autogenerated AsciiDoc heading ids + +When an external target points at an AsciiDoc heading using an autogenerated fragment like `#_extended_attributes`, `formatSectionOutput` resolves it to the local heading range when a pinned checkout is available. + +## Expand resolves explicit AsciiDoc heading ids + +When an external target points at an AsciiDoc heading using an explicit section id like `#custom-layout`, `lat expand` includes the resolved local heading range in the context block. + +## Tilde path expands to home directory + +When `lat.md/config.local.json` uses a `path` that starts with `~/`, lat expands it to the current user's home directory before validating the checkout and building navigation targets. + +## Expand includes external context + +`lat expand` keeps authored external refs intact and appends context describing the external handle, pinned revision, and active destination. + +## Check md reports stale local external revision + +When `lat.md/config.local.json` points at a git checkout whose `HEAD` does not match the configured pinned revision, `check md` reports a local override error. + +## Check md can ignore local overrides + +When `lat check --ignore-local-overrides` or `lat check md --ignore-local-overrides` is used, `lat.md/config.local.json` is ignored for that run, so stale or machine-specific local override errors do not fail the check. + +## Get source returns canonical repo URL + +`lat get-source` returns the configured canonical repository URL when a handle has no valid local override to prefer. + +## Get source returns local repo path + +`lat get-source` returns the local checkout path when `lat.md/config.local.json` points at the pinned revision for that handle. diff --git a/lat.md/tests/mcp.md b/lat.md/tests/mcp.md index da1f748..a79338b 100644 --- a/lat.md/tests/mcp.md +++ b/lat.md/tests/mcp.md @@ -9,7 +9,7 @@ Functional tests for the MCP server. Spawns `lat mcp` against the `basic-project Tests in `tests/mcp.test.ts`. ## Lists all tools -Server exposes exactly `lat_check`, `lat_expand`, `lat_locate`, `lat_refs`, `lat_search`, `lat_section`. +Server exposes exactly `lat_check`, `lat_expand`, `lat_get_source`, `lat_locate`, `lat_refs`, `lat_search`, `lat_section`. ## lat_locate finds a section Calling `lat_locate` with query `"Testing"` returns a result containing `dev-process#Testing`. @@ -42,3 +42,7 @@ Semantic search for a latency/response-times query returns results containing th ## lat_search returns no results message When `LAT_LLM_KEY` is not set, `lat_search` returns an error with `isError: true` explaining the missing key. + +## lat_get_source returns canonical repo URL + +Calling `lat_get_source` with a configured external handle returns the canonical repository URL when no local override is active. diff --git a/lat.md/tests/tests.md b/lat.md/tests/tests.md index a182b3f..4d59bc6 100644 --- a/lat.md/tests/tests.md +++ b/lat.md/tests/tests.md @@ -29,3 +29,4 @@ Shared patterns for writing and organizing tests in this project. - [[section]] — getSection core function and formatSectionOutput formatter - [[hook]] — Stop hook conditional blocking and diff analysis - [[ts-fallback]] — Pure-TypeScript code-ref scanner fallback without ripgrep +- [[external-sources]] — External source handles and local override navigation diff --git a/package.json b/package.json index 9edbd4c..80ac155 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "web-tree-sitter": "^0.26.6", + "yaml": "^2.8.3", "zod": "^4.3.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3759de..87d743e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: web-tree-sitter: specifier: ^0.26.6 version: 0.26.6 + yaml: + specifier: ^2.8.3 + version: 2.8.3 zod: specifier: ^4.3.6 version: 4.3.6 @@ -74,7 +77,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.3) packages: @@ -1351,8 +1354,8 @@ packages: utf-8-validate: optional: true - yaml@2.8.2: - resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} hasBin: true @@ -1453,7 +1456,7 @@ snapshots: picomatch: 2.3.1 toml: 3.0.0 write: 2.0.0 - yaml: 2.8.2 + yaml: 2.8.3 '@hono/node-server@1.19.11(hono@4.12.7)': dependencies: @@ -1661,13 +1664,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.3) '@vitest/pretty-format@3.2.4': dependencies: @@ -2571,13 +2574,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2): + vite-node@3.2.4(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -2592,7 +2595,7 @@ snapshots: - tsx - yaml - vite@7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -2604,13 +2607,13 @@ snapshots: '@types/node': 25.3.0 fsevents: 2.3.3 tsx: 4.21.0 - yaml: 2.8.2 + yaml: 2.8.3 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -2628,8 +2631,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.3) + vite-node: 3.2.4(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 @@ -2676,7 +2679,7 @@ snapshots: ws@8.19.0: {} - yaml@2.8.2: {} + yaml@2.8.3: {} zod-to-json-schema@3.25.1(zod@4.3.6): dependencies: diff --git a/src/cli/check.ts b/src/cli/check.ts index e825f06..80fcaf1 100644 --- a/src/cli/check.ts +++ b/src/cli/check.ts @@ -17,6 +17,15 @@ import { SOURCE_EXTENSIONS, clearSymbolCache } from '../source-parser.js'; import { walkEntries } from '../walk.js'; import type { CmdContext, CmdResult, Styler } from '../context.js'; import { INIT_VERSION, readInitVersion } from '../init-version.js'; +import { + loadExternalSources, + parseExternalTarget, + validateExternalSources, +} from '../external-sources.js'; + +export type CheckMdOptions = { + ignoreLocalOverrides?: boolean; +}; export type CheckError = { file: string; @@ -136,16 +145,29 @@ async function tryResolveSourceRef( } } -export async function checkMd(latticeDir: string): Promise { +export async function checkMd( + latticeDir: string, + opts: CheckMdOptions = {}, +): Promise { clearSymbolCache(); const projectRoot = dirname(latticeDir); const files = await listLatticeFiles(latticeDir); const allSections = await loadAllSections(latticeDir); + const externalSources = loadExternalSources(projectRoot, { + ignoreLocalOverrides: opts.ignoreLocalOverrides, + }); const flat = flattenSections(allSections); const sectionIds = new Set(flat.map((s) => s.id.toLowerCase())); const fileIndex = buildFileIndex(allSections); - const errors: CheckError[] = []; + const errors: CheckError[] = validateExternalSources(externalSources).map( + (err) => ({ + file: relative(process.cwd(), err.file), + line: 1, + target: err.handle ?? '', + message: err.message, + }), + ); for (const file of files) { const content = await readFile(file, 'utf-8'); @@ -153,6 +175,9 @@ export async function checkMd(latticeDir: string): Promise { const relPath = relative(process.cwd(), file); for (const ref of refs) { + const external = parseExternalTarget(ref.target, externalSources.sources); + if (external) continue; + const { resolved, ambiguous, suggested } = resolveRef( ref.target, sectionIds, @@ -189,12 +214,16 @@ export async function checkCodeRefs(latticeDir: string): Promise { const flat = flattenSections(allSections); const sectionIds = new Set(flat.map((s) => s.id.toLowerCase())); const fileIndex = buildFileIndex(allSections); + const externalSources = loadExternalSources(projectRoot); const scan = await scanCodeRefs(projectRoot); const errors: CheckError[] = []; const mentionedSections = new Set(); for (const ref of scan.refs) { + const external = parseExternalTarget(ref.target, externalSources.sources); + if (external) continue; + const { resolved, ambiguous, suggested } = resolveRef( ref.target, sectionIds, @@ -295,6 +324,7 @@ export async function checkIndex(latticeDir: string): Promise { // Flag non-.md files — only markdown belongs in lat.md/ for (const p of allPaths) { const name = p.includes('/') ? p.slice(p.lastIndexOf('/') + 1) : p; + if (p === 'config.local.json') continue; if (!name.endsWith('.md')) { const relDir = basename(latticeDir) + '/'; errors.push({ @@ -483,9 +513,16 @@ function formatErrorCount(count: number, s: Styler): string { // --- Unified command functions --- -export async function checkAllCommand(ctx: CmdContext): Promise { +type CheckCommandOptions = { + ignoreLocalOverrides?: boolean; +}; + +export async function checkAllCommand( + ctx: CmdContext, + opts: CheckCommandOptions = {}, +): Promise { const startTime = Date.now(); - const md = await checkMd(ctx.latDir); + const md = await checkMd(ctx.latDir, opts); const code = await checkCodeRefs(ctx.latDir); const indexErrors = await checkIndex(ctx.latDir); const sectionErrors = await checkSections(ctx.latDir); @@ -575,8 +612,11 @@ export async function checkAllCommand(ctx: CmdContext): Promise { return { output: lines.join('\n') }; } -export async function checkMdCommand(ctx: CmdContext): Promise { - const { errors, files } = await checkMd(ctx.latDir); +export async function checkMdCommand( + ctx: CmdContext, + opts: CheckCommandOptions = {}, +): Promise { + const { errors, files } = await checkMd(ctx.latDir, opts); const s = ctx.styler; const lines: string[] = [formatFileStats(files, s)]; diff --git a/src/cli/expand.ts b/src/cli/expand.ts index 3be2d99..b5c7b3d 100644 --- a/src/cli/expand.ts +++ b/src/cli/expand.ts @@ -6,6 +6,12 @@ import { type SectionMatch, } from '../lattice.js'; import type { CmdContext, CmdResult } from '../context.js'; +import { + loadExternalSources, + parseExternalTarget, + resolveExternalTarget, + type ExternalResolution, +} from '../external-sources.js'; const WIKI_LINK_RE = /\[\[([^\]]+)\]\]/g; @@ -20,6 +26,10 @@ type ResolvedRef = { alternatives: SectionMatch[]; }; +type ExpandedRef = + | { kind: 'section'; data: ResolvedRef } + | { kind: 'external'; data: ExternalResolution }; + /** * Resolve [[refs]] in text and return the expanded output. * Returns null if there are no wiki links, or if resolution fails. @@ -32,19 +42,32 @@ export async function expandPrompt( if (refs.length === 0) return null; const allSections = await loadAllSections(ctx.latDir); - const resolved = new Map(); + const externalSources = loadExternalSources(ctx.projectRoot); + const resolved = new Map(); const errors: string[] = []; for (const match of refs) { const target = match[1]; if (resolved.has(target)) continue; + const external = parseExternalTarget(target, externalSources.sources); + if (external) { + resolved.set(target, { + kind: 'external', + data: resolveExternalTarget(external), + }); + continue; + } + const matches = findSections(allSections, target); if (matches.length >= 1) { resolved.set(target, { - target, - best: matches[0], - alternatives: matches.slice(1), + kind: 'section', + data: { + target, + best: matches[0], + alternatives: matches.slice(1), + }, }); } else { errors.push(`No section found for [[${target}]]`); @@ -56,21 +79,46 @@ export async function expandPrompt( // Replace [[refs]] inline let output = text.replace(WIKI_LINK_RE, (_match, target: string) => { const ref = resolved.get(target)!; - return `[[${ref.best.section.id}]]`; + if (ref.kind === 'external') { + return `[[${target}]]`; + } + return `[[${ref.data.best.section.id}]]`; }); // Append context block as nested outliner output += '\n\n\n'; for (const ref of resolved.values()) { + if (ref.kind === 'external') { + output += `* \`[[${ref.data.target}]]\` is referring to:\n`; + output += ` * external source ${ref.data.handle}\n`; + if (ref.data.rev) { + output += ` * pinned rev: ${ref.data.rev}\n`; + } + if (ref.data.activeKind === 'local' && ref.data.localPath) { + const localLoc = ref.data.line + ? ref.data.endLine && ref.data.endLine !== ref.data.line + ? `${ref.data.localPath}:${ref.data.line}-${ref.data.endLine}` + : `${ref.data.localPath}:${ref.data.line}` + : ref.data.localPath; + output += ` * local: ${ref.data.localFileUrl}\n`; + output += ` * path: ${localLoc}\n`; + } else { + output += ` * canonical: ${ref.data.browseUrl}\n`; + } + continue; + } + const isExact = - ref.best.reason === 'exact match' || - ref.best.reason.startsWith('file stem expanded'); - const all = isExact ? [ref.best] : [ref.best, ...ref.alternatives]; + ref.data.best.reason === 'exact match' || + ref.data.best.reason.startsWith('file stem expanded'); + const all = isExact + ? [ref.data.best] + : [ref.data.best, ...ref.data.alternatives]; if (isExact) { - output += `* \`[[${ref.target}]]\` is referring to:\n`; + output += `* \`[[${ref.data.target}]]\` is referring to:\n`; } else { - output += `* \`[[${ref.target}]]\` might be referring to either of the following:\n`; + output += `* \`[[${ref.data.target}]]\` might be referring to either of the following:\n`; } for (const m of all) { @@ -101,8 +149,10 @@ export async function expandCommand( // Resolution failed — find which ref is broken const allSections = await loadAllSections(ctx.latDir); + const externalSources = loadExternalSources(ctx.projectRoot); for (const match of refs) { const target = match[1]; + if (parseExternalTarget(target, externalSources.sources)) continue; const matches = findSections(allSections, target); if (matches.length === 0) { const s = ctx.styler; diff --git a/src/cli/get-source.ts b/src/cli/get-source.ts new file mode 100644 index 0000000..dde0e95 --- /dev/null +++ b/src/cli/get-source.ts @@ -0,0 +1,35 @@ +import type { CmdContext, CmdResult } from '../context.js'; +import { + loadExternalSources, + resolveExternalSourceHandle, +} from '../external-sources.js'; + +export async function getSourceCommand( + ctx: CmdContext, + externalSource: string, +): Promise { + const handle = externalSource.trim(); + const { sources } = loadExternalSources(ctx.projectRoot); + const source = sources[handle]; + + if (!source) { + const handles = Object.keys(sources).sort(); + return { + output: + handles.length > 0 + ? `No external source "${handle}". Available handles: ${handles.join(', ')}` + : 'No external sources configured.', + isError: true, + }; + } + + const resolved = resolveExternalSourceHandle(handle, source); + if (!resolved) { + return { + output: `External source "${handle}" has no usable local path or canonical repo URL.`, + isError: true, + }; + } + + return { output: resolved.activeTarget }; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 708e323..59c8971 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -83,22 +83,40 @@ program handleResult(await refsCommand(ctx, query, scope)); }); +program + .command('get-source') + .description('Show the active location for a configured external source') + .argument('', 'external source handle to look up') + .action(async (externalSource: string) => { + const ctx = resolveContext(program.opts()); + const { getSourceCommand } = await import('./get-source.js'); + handleResult(await getSourceCommand(ctx, externalSource)); + }); + const check = program .command('check') .description('Validate links and code references') - .action(async () => { + .option( + '--ignore-local-overrides', + 'ignore local external source overrides from lat.md/config.local.json', + ) + .action(async (opts: { ignoreLocalOverrides?: boolean }) => { const ctx = resolveContext(program.opts()); const { checkAllCommand } = await import('./check.js'); - handleResult(await checkAllCommand(ctx)); + handleResult(await checkAllCommand(ctx, opts)); }); check .command('md') .description('Validate wiki links in lat.md markdown files') - .action(async () => { + .option( + '--ignore-local-overrides', + 'ignore local external source overrides from lat.md/config.local.json', + ) + .action(async (opts: { ignoreLocalOverrides?: boolean }) => { const ctx = resolveContext(program.opts()); const { checkMdCommand } = await import('./check.js'); - handleResult(await checkMdCommand(ctx)); + handleResult(await checkMdCommand(ctx, opts)); }); check @@ -246,9 +264,21 @@ program .description('Show configuration file path') .action(async () => { const { getConfigPath } = await import('../config.js'); + const { findProjectRoot } = await import('../lattice.js'); + const { getProjectLocalConfigPath } = + await import('../external-sources.js'); const configPath = getConfigPath(); const exists = existsSync(configPath); console.log(`Config file: ${configPath}${exists ? '' : ' (not found)'}`); + + const projectRoot = findProjectRoot(program.opts().dir); + if (projectRoot) { + const localConfigPath = getProjectLocalConfigPath(projectRoot); + const localExists = existsSync(localConfigPath); + console.log( + `Project local config: ${localConfigPath}${localExists ? '' : ' (not found)'}`, + ); + } }); await program.parseAsync(); diff --git a/src/cli/init.ts b/src/cli/init.ts index 9490cdc..478f17c 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -27,6 +27,7 @@ import { writeInitMeta, readFileHash, contentHash } from '../init-version.js'; import { getLocalVersion, fetchLatestVersion } from '../version.js'; import { selectMenu, type SelectOption } from './select-menu.js'; import { checklistMenu } from './checklist-menu.js'; +import { getConfiguredExternalPaths } from '../external-sources.js'; async function confirm( rl: ReturnType, @@ -287,6 +288,36 @@ function ensureGitignored(root: string, entry: string): void { } } +function ensureLocalIgnored(dir: string, entry: string): void { + const gitignorePath = join(dir, '.gitignore'); + + if (existsSync(gitignorePath)) { + const content = readFileSync(gitignorePath, 'utf-8'); + const lines = content.split('\n').map((line) => line.trim()); + if (lines.includes(entry)) return; + let updated = content; + if (!updated.endsWith('\n')) updated += '\n'; + writeFileSync(gitignorePath, updated + entry + '\n'); + return; + } + + writeFileSync(gitignorePath, entry + '\n'); +} + +function isGitignored(root: string, entry: string): boolean { + const gitDir = join(root, '.git'); + if (!existsSync(gitDir)) return false; + try { + execSync(`git check-ignore -q -- "${entry}"`, { + cwd: root, + stdio: ['ignore', 'ignore', 'ignore'], + }); + return true; + } catch { + return false; + } +} + // ── MCP command detection ──────────────────────────────────────────── /** @@ -328,6 +359,107 @@ type McpConfig = Record< Record >; +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function mergeClaudeAdditionalDirectories( + settings: Record, + paths: string[], +): Record { + const next = { ...settings }; + const existing = Array.isArray(next.additionalDirectories) + ? next.additionalDirectories.filter( + (value): value is string => typeof value === 'string', + ) + : []; + next.additionalDirectories = [...new Set([...existing, ...paths])]; + return next; +} + +export function mergeOpenCodeExternalDirectoryPermissions( + config: Record, + paths: string[], +): Record { + const next = { ...config }; + if (!next.$schema) { + next.$schema = 'https://opencode.ai/config.json'; + } + + const permission = asRecord(next.permission) ?? {}; + const externalDirectory = asRecord(permission.external_directory) ?? {}; + for (const dir of paths) { + const pattern = join(resolve(dir), '**'); + if (externalDirectory[pattern] === undefined) { + externalDirectory[pattern] = 'allow'; + } + } + permission.external_directory = externalDirectory; + next.permission = permission; + return next; +} + +function syncClaudeAdditionalDirectories( + settingsPath: string, + paths: string[], +): 'created' | 'updated' | 'already' | 'skipped' | 'invalid' { + if (paths.length === 0) return 'skipped'; + + const existedBefore = existsSync(settingsPath); + let settings: Record = {}; + if (existedBefore) { + try { + settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); + } catch { + return 'invalid'; + } + } + + const next = mergeClaudeAdditionalDirectories(settings, paths); + const before = JSON.stringify(settings, null, 2); + const after = JSON.stringify(next, null, 2); + if (before === after && existsSync(settingsPath)) return 'already'; + + mkdirSync(join(settingsPath, '..'), { recursive: true }); + writeFileSync(settingsPath, after + '\n'); + return existedBefore ? 'updated' : 'created'; +} + +function syncOpenCodeExternalDirectories( + root: string, + paths: string[], +): 'created' | 'updated' | 'already' | 'shared-skip' | 'invalid' | 'skipped' { + if (paths.length === 0) return 'skipped'; + + const configPath = join(root, 'opencode.json'); + if (!existsSync(configPath)) { + const next = mergeOpenCodeExternalDirectoryPermissions({}, paths); + writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n'); + return 'created'; + } + + if (!isGitignored(root, 'opencode.json')) { + return 'shared-skip'; + } + + let config: Record; + try { + config = JSON.parse(readFileSync(configPath, 'utf-8')); + } catch { + return 'invalid'; + } + + const next = mergeOpenCodeExternalDirectoryPermissions(config, paths); + const before = JSON.stringify(config, null, 2); + const after = JSON.stringify(next, null, 2); + if (before === after) return 'already'; + + writeFileSync(configPath, after + '\n'); + return 'updated'; +} + function hasMcpServer(configPath: string, key: string): boolean { if (!existsSync(configPath)) return false; try { @@ -728,6 +860,29 @@ async function setupClaudeCode( // Ensure .claude is gitignored (settings contain local absolute paths) ensureGitignored(root, '.claude'); + const externalPaths = getConfiguredExternalPaths(root); + const localSettingsPath = join(claudeDir, 'settings.local.json'); + const extraDirsResult = syncClaudeAdditionalDirectories( + localSettingsPath, + externalPaths, + ); + if (extraDirsResult === 'created') { + console.log( + styleText('green', ' Additional directories') + + ' added to .claude/settings.local.json', + ); + } else if (extraDirsResult === 'updated') { + console.log( + styleText('green', ' Additional directories') + + ' updated in .claude/settings.local.json', + ); + } else if (extraDirsResult === 'invalid') { + console.log( + styleText('yellow', ' Warning:') + + ' could not parse .claude/settings.local.json — skipped external directories', + ); + } + // MCP server → .mcp.json at project root console.log(''); console.log( @@ -1005,6 +1160,31 @@ async function setupOpenCode( // Ensure .opencode is gitignored (plugin contains local absolute paths) ensureGitignored(root, '.opencode'); + + const externalPaths = getConfiguredExternalPaths(root); + if (!existsSync(join(root, 'opencode.json'))) { + ensureGitignored(root, 'opencode.json'); + } + const openCodeResult = syncOpenCodeExternalDirectories(root, externalPaths); + if (openCodeResult === 'created') { + console.log( + styleText('green', ' Local permissions') + ' written to opencode.json', + ); + } else if (openCodeResult === 'updated') { + console.log( + styleText('green', ' Local permissions') + ' updated in opencode.json', + ); + } else if (openCodeResult === 'shared-skip') { + console.log( + styleText('yellow', ' Local permissions skipped') + + ' because opencode.json exists and is not gitignored', + ); + } else if (openCodeResult === 'invalid') { + console.log( + styleText('yellow', ' Warning:') + + ' could not parse opencode.json — skipped external directory permissions', + ); + } } async function setupCodex( @@ -1294,6 +1474,8 @@ export async function initCmd(targetDir?: string): Promise { console.log(styleText('green', 'Created lat.md/')); } + ensureLocalIgnored(latDir, 'config.local.json'); + // Step 2: Which coding agents do you use? (interactive select menu) console.log(''); diff --git a/src/cli/refs.ts b/src/cli/refs.ts index 77b8949..afbdefc 100644 --- a/src/cli/refs.ts +++ b/src/cli/refs.ts @@ -15,6 +15,10 @@ import { import { formatResultList } from '../format.js'; import { scanCodeRefs } from '../code-refs.js'; import type { CmdContext, CmdResult } from '../context.js'; +import { + loadExternalSources, + parseExternalTarget, +} from '../external-sources.js'; export type Scope = 'md' | 'code' | 'md+code'; @@ -172,6 +176,70 @@ async function findSourceRefs( return { kind: 'found', target, mdRefs, codeRefs }; } +async function findExternalRefs( + latDir: string, + projectRoot: string, + query: string, + scope: Scope, +): Promise { + const colonIdx = query.indexOf(':'); + const target: Section = { + id: query, + heading: colonIdx === -1 ? query : query.slice(colonIdx + 1), + depth: 0, + file: query, + filePath: query, + children: [], + startLine: 0, + endLine: 0, + firstParagraph: '', + }; + + const allSections = await loadAllSections(latDir); + const flat = flattenSections(allSections); + const mdRefs: SectionMatch[] = []; + const codeRefs: string[] = []; + const queryLower = query.toLowerCase(); + + if (scope === 'md' || scope === 'md+code') { + const files = await listLatticeFiles(latDir); + const matchingFromSections = new Set(); + for (const file of files) { + const content = await readFile(file, 'utf-8'); + const fileRefs = extractRefs(file, content, projectRoot); + for (const ref of fileRefs) { + if (ref.target.toLowerCase() === queryLower) { + matchingFromSections.add(ref.fromSection.toLowerCase()); + } + } + } + + if (matchingFromSections.size > 0) { + const referrers = flat.filter((s) => + matchingFromSections.has(s.id.toLowerCase()), + ); + for (const s of referrers) { + mdRefs.push({ section: s, reason: 'wiki link' }); + } + } + } + + if (scope === 'code' || scope === 'md+code') { + const { refs: scannedRefs } = await scanCodeRefs(projectRoot); + for (const ref of scannedRefs) { + if (ref.target.toLowerCase() === queryLower) { + const displayPath = relative( + process.cwd(), + join(projectRoot, ref.file), + ); + codeRefs.push(`${displayPath}:${ref.line}`); + } + } + } + + return { kind: 'found', target, mdRefs, codeRefs }; +} + /** * Find all sections and code locations that reference a given section or * source file. Accepts section ids (full-path, short-form) and source file @@ -184,6 +252,11 @@ export async function findRefs( scope: Scope, ): Promise { query = query.replace(/^\[\[|\]\]$/g, ''); + const externalSources = loadExternalSources(ctx.projectRoot); + + if (parseExternalTarget(query, externalSources.sources)) { + return findExternalRefs(ctx.latDir, ctx.projectRoot, query, scope); + } // Source file queries bypass section resolution if (isSourceQuery(query, ctx.projectRoot)) { diff --git a/src/cli/section.ts b/src/cli/section.ts index 07ac898..72561c8 100644 --- a/src/cli/section.ts +++ b/src/cli/section.ts @@ -15,6 +15,12 @@ import { scanCodeRefs } from '../code-refs.js'; import { SOURCE_EXTENSIONS, resolveSourceSymbol } from '../source-parser.js'; import type { CmdContext, CmdResult } from '../context.js'; import { formatSectionId, formatNavHints } from '../format.js'; +import { + loadExternalSources, + parseExternalTarget, + resolveExternalTarget, + type ExternalResolution, +} from '../external-sources.js'; export type CodeBackRef = { file: string; @@ -36,6 +42,7 @@ export type SectionFound = { content: string; outgoingRefs: { target: string; resolved: Section }[]; outgoingSourceRefs: SourceRef[]; + outgoingExternalRefs: ExternalResolution[]; incomingRefs: SectionMatch[]; codeRefs: CodeBackRef[]; }; @@ -85,14 +92,26 @@ export async function getSection( const flat = flattenSections(allSections); const sectionIds = new Set(flat.map((s) => s.id.toLowerCase())); const fileIndex = buildFileIndex(allSections); + const externalSources = loadExternalSources(ctx.projectRoot); const sectionRefs = extractRefs(absPath, fileContent, ctx.projectRoot); const sectionId = section.id.toLowerCase(); const outgoingRefs: { target: string; resolved: Section }[] = []; const outgoingSourceRefs: SourceRef[] = []; + const outgoingExternalRefs: ExternalResolution[] = []; const seen = new Set(); for (const ref of sectionRefs) { if (ref.fromSection.toLowerCase() !== sectionId) continue; + const external = parseExternalTarget(ref.target, externalSources.sources); + if (external) { + const targetLower = ref.target.toLowerCase(); + if (!seen.has(targetLower)) { + seen.add(targetLower); + outgoingExternalRefs.push(resolveExternalTarget(external)); + } + continue; + } + // Detect source code references by file extension const hashIdx = ref.target.indexOf('#'); const filePart = hashIdx === -1 ? ref.target : ref.target.slice(0, hashIdx); @@ -216,6 +235,7 @@ export async function getSection( content, outgoingRefs, outgoingSourceRefs, + outgoingExternalRefs, incomingRefs, codeRefs, }; @@ -243,6 +263,7 @@ export function formatSectionOutput( content, outgoingRefs, outgoingSourceRefs, + outgoingExternalRefs, incomingRefs, codeRefs, } = result; @@ -263,7 +284,11 @@ export function formatSectionOutput( quoted, ]; - if (outgoingRefs.length > 0 || outgoingSourceRefs.length > 0) { + if ( + outgoingRefs.length > 0 || + outgoingSourceRefs.length > 0 || + outgoingExternalRefs.length > 0 + ) { parts.push('', '## This section references:', ''); for (const ref of outgoingRefs) { const body = ref.resolved.firstParagraph @@ -287,6 +312,26 @@ export function formatSectionOutput( } } } + for (const ref of outgoingExternalRefs) { + const localLoc = ref.localPath + ? ref.line + ? ref.endLine && ref.endLine !== ref.line + ? `${ref.localPath}:${ref.line}-${ref.endLine}` + : `${ref.localPath}:${ref.line}` + : ref.localPath + : null; + const active = + ref.activeKind === 'local' + ? `${s.dim(' -> ')}${s.cyan(ref.activeTarget)}` + : `${s.dim(' -> ')}${s.cyan(ref.browseUrl)}`; + const detail = + ref.activeKind === 'local' && localLoc + ? `${s.dim(` (${localLoc})`)}` + : ref.rev + ? `${s.dim(` (rev ${ref.rev})`)}` + : ''; + parts.push(`${s.dim('*')} [[${s.cyan(ref.target)}]]${detail}${active}`); + } } if (incomingRefs.length > 0) { diff --git a/src/external-sources.ts b/src/external-sources.ts new file mode 100644 index 0000000..82673d3 --- /dev/null +++ b/src/external-sources.ts @@ -0,0 +1,526 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { extname, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { parse as parseYaml } from 'yaml'; + +export type ExternalSource = { + repo?: string; + rev?: string; + browse?: string; + path?: string; +}; + +export type ExternalSourcesState = { + rootConfigPath: string; + localConfigPath: string; + sources: Record; + errors: { file: string; message: string }[]; +}; + +export type LoadExternalSourcesOptions = { + ignoreLocalOverrides?: boolean; +}; + +export type ExternalTarget = { + target: string; + handle: string; + path: string; + fragment: string; + source: ExternalSource; +}; + +export type ExternalResolution = { + target: string; + handle: string; + repo?: string; + rev?: string; + path: string; + fragment: string; + browseUrl: string; + activeTarget: string; + activeKind: 'local' | 'canonical'; + localPath?: string; + localFileUrl?: string; + line?: number; + endLine?: number; +}; + +export type ExternalSourceHandleResolution = { + handle: string; + repo?: string; + rev?: string; + localPath?: string; + activeKind: 'local' | 'canonical'; + activeTarget: string; +}; + +type LocalSourceValidation = { + repoPath: string; + head: string; + resolvedRev: string; + error?: string; +}; + +const localSourceCache = new Map(); + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeSourceConfig(value: unknown): ExternalSource | null { + if (!isRecord(value)) return null; + const source: ExternalSource = {}; + if (typeof value.repo === 'string') source.repo = value.repo; + if (typeof value.rev === 'string') source.rev = value.rev; + if (typeof value.browse === 'string') source.browse = value.browse; + if (typeof value.path === 'string') source.path = value.path; + return source; +} + +function parseCanonicalExternalSources( + content: string, +): Record { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return {}; + + const parsed = parseYaml(match[1]); + if (!isRecord(parsed) || !isRecord(parsed.lat)) return {}; + + const raw = parsed.lat['external-sources']; + if (!isRecord(raw)) return {}; + + const sources: Record = {}; + for (const [handle, value] of Object.entries(raw)) { + const source = normalizeSourceConfig(value); + if (source) sources[handle] = source; + } + return sources; +} + +function parseLocalExternalSources( + content: string, +): Record { + const parsed = JSON.parse(content); + if (!isRecord(parsed) || !isRecord(parsed.lat)) return {}; + + const raw = parsed.lat['external-sources']; + if (!isRecord(raw)) return {}; + + const sources: Record = {}; + for (const [handle, value] of Object.entries(raw)) { + const source = normalizeSourceConfig(value); + if (source?.path) { + sources[handle] = { path: source.path }; + } + } + return sources; +} + +export function getProjectLocalConfigPath(projectRoot: string): string { + return join(projectRoot, 'lat.md', 'config.local.json'); +} + +export function loadExternalSources( + projectRoot: string, + opts: LoadExternalSourcesOptions = {}, +): ExternalSourcesState { + const rootConfigPath = join(projectRoot, 'lat.md', 'lat.md'); + const localConfigPath = getProjectLocalConfigPath(projectRoot); + const errors: { file: string; message: string }[] = []; + let canonical: Record = {}; + let local: Record = {}; + + if (existsSync(rootConfigPath)) { + try { + canonical = parseCanonicalExternalSources( + readFileSync(rootConfigPath, 'utf-8'), + ); + } catch (err) { + errors.push({ + file: rootConfigPath, + message: `invalid YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`, + }); + } + } + + if (!opts.ignoreLocalOverrides && existsSync(localConfigPath)) { + try { + local = parseLocalExternalSources(readFileSync(localConfigPath, 'utf-8')); + } catch (err) { + errors.push({ + file: localConfigPath, + message: `invalid JSON: ${err instanceof Error ? err.message : String(err)}`, + }); + } + } + + const sources: Record = {}; + for (const handle of new Set([ + ...Object.keys(canonical), + ...Object.keys(local), + ])) { + sources[handle] = { ...canonical[handle], ...local[handle] }; + } + + return { rootConfigPath, localConfigPath, sources, errors }; +} + +export function parseExternalTarget( + target: string, + sources: Record, +): ExternalTarget | null { + const colon = target.indexOf(':'); + if (colon <= 0) return null; + const handle = target.slice(0, colon); + const source = sources[handle]; + if (!source) return null; + + const rest = target.slice(colon + 1); + if (!rest) return null; + + const hash = rest.indexOf('#'); + return { + target, + handle, + path: hash === -1 ? rest : rest.slice(0, hash), + fragment: hash === -1 ? '' : rest.slice(hash + 1), + source, + }; +} + +export function getConfiguredExternalPaths(projectRoot: string): string[] { + const { sources } = loadExternalSources(projectRoot); + return [ + ...new Set( + Object.values(sources) + .map((s) => expandHomePath(s.path)) + .filter((p): p is string => typeof p === 'string' && p.length > 0), + ), + ]; +} + +export function expandHomePath( + pathValue: string | undefined, +): string | undefined { + if (!pathValue) return pathValue; + if (pathValue === '~') return homedir(); + if (pathValue.startsWith('~/')) return join(homedir(), pathValue.slice(2)); + return pathValue; +} + +function validateLocalSource( + source: ExternalSource, +): LocalSourceValidation | null { + if (!source.path || !source.rev) return null; + + const configuredPath = expandHomePath(source.path); + if (!configuredPath) return null; + + const key = `${configuredPath}\0${source.rev}`; + const cached = localSourceCache.get(key); + if (cached) return cached; + + const result: LocalSourceValidation = { + repoPath: resolve(configuredPath), + head: '', + resolvedRev: '', + }; + + try { + if (!existsSync(result.repoPath)) { + result.error = `local path "${result.repoPath}" does not exist`; + } else if (!statSync(result.repoPath).isDirectory()) { + result.error = `local path "${result.repoPath}" is not a directory`; + } else { + result.resolvedRev = execFileSync( + 'git', + ['-C', result.repoPath, 'rev-parse', `${source.rev}^{commit}`], + { encoding: 'utf-8' }, + ).trim(); + result.head = execFileSync( + 'git', + ['-C', result.repoPath, 'rev-parse', 'HEAD'], + { + encoding: 'utf-8', + }, + ).trim(); + if (result.head !== result.resolvedRev) { + result.error = `local path "${result.repoPath}" is at ${result.head.slice(0, 12)} but expected ${result.resolvedRev.slice(0, 12)} for rev "${source.rev}"`; + } + } + } catch (err) { + result.error = + err instanceof Error && err.message + ? `failed to validate local path "${result.repoPath}": ${err.message}` + : `failed to validate local path "${result.repoPath}"`; + } + + localSourceCache.set(key, result); + return result; +} + +function formatBrowseUrl(target: ExternalTarget): string { + const template = target.source.browse ?? ''; + return template + .replaceAll('{path}', target.path) + .replaceAll('{fragment}', target.fragment) + .replaceAll('{rev}', target.source.rev ?? '') + .replaceAll('{repo}', target.source.repo ?? '') + .replace(/#$/, '') + .replace(/[?&]$/, ''); +} + +export function parseLineFragment( + fragment: string, +): { line: number; endLine?: number } | null { + const match = fragment.match(/^L(\d+)(?:-L?(\d+))?$/i); + if (!match) return null; + const line = Number(match[1]); + const endLine = match[2] ? Number(match[2]) : undefined; + return endLine && endLine !== line ? { line, endLine } : { line }; +} + +type AsciiDocSection = { + level: number; + title: string; + ids: string[]; + line: number; + endLine: number; +}; + +function isAsciiDocPath(path: string): boolean { + const ext = extname(path).toLowerCase(); + return ext === '.adoc' || ext === '.asciidoc'; +} + +function normalizeAsciiDocHeadingTitle(title: string): string { + return title + .replace(/\[\[[^\]]+\]\]/g, '') + .replace(/<[^>]*>/g, '') + .trim(); +} + +function autoAsciiDocId(title: string, seen: Set): string { + const stem = normalizeAsciiDocHeadingTitle(title) + .toLowerCase() + .replace(/[^\w .-]+/g, '') + .replace(/[ .-]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+/, '') + .replace(/_+$/, ''); + const base = stem ? `_${stem}` : '_section'; + + let candidate = base; + let suffix = 2; + while (seen.has(candidate)) { + candidate = `${base}_${suffix}`; + suffix++; + } + seen.add(candidate); + return candidate; +} + +function parseStandaloneAsciiDocId(line: string): string | null { + const trimmed = line.trim(); + const blockAnchor = trimmed.match(/^\[\[([^,\]]+)(?:,[^\]]*)?\]\]$/); + if (blockAnchor) return blockAnchor[1]; + + const shorthand = trimmed.match(/^\[#([^,\]]+)(?:,[^\]]*)?\]$/); + if (shorthand) return shorthand[1]; + + const named = trimmed.match(/^\[(?:[^\]]*,)?id=([^,\]]+)(?:,[^\]]*)?\]$/); + if (named) return named[1]; + + return null; +} + +function parseAsciiDocSections(content: string): AsciiDocSection[] { + const lines = content.split('\n'); + const sections: AsciiDocSection[] = []; + const seenIds = new Set(); + let pendingId: string | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const standaloneId = parseStandaloneAsciiDocId(line); + if (standaloneId) { + pendingId = standaloneId; + continue; + } + + const heading = line.match(/^(={1,6})\s+(.+?)\s*$/); + if (!heading) { + if (line.trim() !== '') pendingId = null; + continue; + } + + const rawTitle = heading[2]; + const inlineIds = [ + ...rawTitle.matchAll(/\[\[([^,\]]+)(?:,[^\]]*)?\]\]/g), + ].map((match) => match[1]); + const title = normalizeAsciiDocHeadingTitle(rawTitle); + const primaryId = pendingId ?? autoAsciiDocId(title, seenIds); + if (pendingId) seenIds.add(primaryId); + const ids = [primaryId, ...inlineIds.filter((id) => id !== primaryId)]; + + sections.push({ + level: heading[1].length, + title, + ids, + line: i + 1, + endLine: lines.length, + }); + pendingId = null; + } + + for (let i = 0; i < sections.length; i++) { + for (let j = i + 1; j < sections.length; j++) { + if (sections[j].level <= sections[i].level) { + sections[i].endLine = sections[j].line - 1; + break; + } + } + } + + return sections; +} + +function parseAsciiDocFragment( + filePath: string, + fragment: string, +): { line: number; endLine?: number } | null { + if (!fragment || !isAsciiDocPath(filePath) || !existsSync(filePath)) + return null; + + let content: string; + try { + content = readFileSync(filePath, 'utf-8'); + } catch { + return null; + } + + const sections = parseAsciiDocSections(content); + const match = sections.find((section) => section.ids.includes(fragment)); + if (!match) return null; + return match.endLine !== match.line + ? { line: match.line, endLine: match.endLine } + : { line: match.line }; +} + +function parseExternalFragment( + localPath: string | undefined, + target: ExternalTarget, +): { line: number; endLine?: number } | null { + return ( + parseLineFragment(target.fragment) || + (localPath ? parseAsciiDocFragment(localPath, target.fragment) : null) + ); +} + +export function resolveExternalTarget( + target: ExternalTarget, +): ExternalResolution { + const browseUrl = formatBrowseUrl(target); + const local = validateLocalSource(target.source); + const localPath = + local && !local.error ? join(local.repoPath, target.path) : undefined; + const lineInfo = parseExternalFragment(localPath, target); + + if (local && !local.error) { + const localFileUrl = + pathToFileURL(localPath!).toString() + + (target.fragment ? `#${target.fragment}` : ''); + return { + target: target.target, + handle: target.handle, + repo: target.source.repo, + rev: target.source.rev, + path: target.path, + fragment: target.fragment, + browseUrl, + activeTarget: localFileUrl, + activeKind: 'local', + localPath, + localFileUrl, + line: lineInfo?.line, + endLine: lineInfo?.endLine, + }; + } + + return { + target: target.target, + handle: target.handle, + repo: target.source.repo, + rev: target.source.rev, + path: target.path, + fragment: target.fragment, + browseUrl, + activeTarget: browseUrl, + activeKind: 'canonical', + line: lineInfo?.line, + endLine: lineInfo?.endLine, + }; +} + +export function resolveExternalSourceHandle( + handle: string, + source: ExternalSource, +): ExternalSourceHandleResolution | null { + const local = validateLocalSource(source); + if (local && !local.error) { + return { + handle, + repo: source.repo, + rev: source.rev, + localPath: local.repoPath, + activeKind: 'local', + activeTarget: local.repoPath, + }; + } + + if (!source.repo) return null; + + return { + handle, + repo: source.repo, + rev: source.rev, + activeKind: 'canonical', + activeTarget: source.repo, + }; +} + +export function validateExternalSources( + state: ExternalSourcesState, +): { file: string; message: string; handle?: string }[] { + const errors: { file: string; message: string; handle?: string }[] = [ + ...state.errors, + ]; + + for (const [handle, source] of Object.entries(state.sources)) { + const missing: string[] = []; + if (!source.repo) missing.push('repo'); + if (!source.rev) missing.push('rev'); + if (!source.browse) missing.push('browse'); + + if (missing.length > 0) { + errors.push({ + file: state.rootConfigPath, + handle, + message: `external source "${handle}" is missing required field${missing.length === 1 ? '' : 's'}: ${missing.join(', ')}`, + }); + } + + const local = validateLocalSource(source); + if (local?.error) { + errors.push({ + file: state.localConfigPath, + handle, + message: `external source "${handle}": ${local.error}`, + }); + } + } + + return errors; +} diff --git a/src/lattice.ts b/src/lattice.ts index 857a345..09c130a 100644 --- a/src/lattice.ts +++ b/src/lattice.ts @@ -6,6 +6,8 @@ import { walkEntries } from './walk.js'; import { visit } from 'unist-util-visit'; import type { Heading, RootContent, Text } from 'mdast'; import type { WikiLink } from './extensions/wiki-link/types.js'; +import { parse as parseYaml } from 'yaml'; +import type { ExternalSource } from './external-sources.js'; export type Section = { id: string; @@ -28,17 +30,45 @@ export type Ref = { export type LatFrontmatter = { requireCodeMention?: boolean; + externalSources?: Record; }; export function parseFrontmatter(content: string): LatFrontmatter { const match = content.match(/^---\n([\s\S]*?)\n---/); if (!match) return {}; - const yaml = match[1]; - const result: LatFrontmatter = {}; - if (/require-code-mention:\s*true/i.test(yaml)) { - result.requireCodeMention = true; + try { + const parsed = parseYaml(match[1]); + if (!parsed || typeof parsed !== 'object' || !('lat' in parsed)) return {}; + + const lat = (parsed as { lat?: Record }).lat; + if (!lat || typeof lat !== 'object') return {}; + + const result: LatFrontmatter = {}; + if (lat['require-code-mention'] === true) { + result.requireCodeMention = true; + } + + const rawExternalSources = lat['external-sources']; + if (rawExternalSources && typeof rawExternalSources === 'object') { + const externalSources: Record = {}; + for (const [handle, value] of Object.entries(rawExternalSources)) { + if (!value || typeof value !== 'object') continue; + const source: ExternalSource = {}; + if (typeof value.repo === 'string') source.repo = value.repo; + if (typeof value.rev === 'string') source.rev = value.rev; + if (typeof value.browse === 'string') source.browse = value.browse; + if (typeof value.path === 'string') source.path = value.path; + externalSources[handle] = source; + } + if (Object.keys(externalSources).length > 0) { + result.externalSources = externalSources; + } + } + + return result; + } catch { + return {}; } - return result; } export function findLatticeDir(from?: string): string | null { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 9b38c07..70c00d8 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -10,6 +10,7 @@ import { searchCommand } from '../cli/search.js'; import { expandCommand } from '../cli/expand.js'; import { checkAllCommand } from '../cli/check.js'; import { refsCommand, type Scope } from '../cli/refs.js'; +import { getSourceCommand } from '../cli/get-source.js'; function toMcp(result: CmdResult) { const content = [{ type: 'text' as const, text: result.output }]; @@ -95,6 +96,18 @@ export async function startMcpServer(): Promise { toMcp(await refsCommand(ctx, query, scope as Scope)), ); + server.tool( + 'lat_get_source', + 'Return the active location for a configured external source handle', + { + externalSource: z + .string() + .describe('External source handle (e.g. "architecture-docs")'), + }, + async ({ externalSource }) => + toMcp(await getSourceCommand(ctx, externalSource)), + ); + const transport = new StdioServerTransport(); await server.connect(transport); } diff --git a/templates/init/.gitignore b/templates/init/.gitignore index 00f1153..aa24bbf 100644 --- a/templates/init/.gitignore +++ b/templates/init/.gitignore @@ -1,2 +1,3 @@ .obsidian .cache +config.local.json diff --git a/templates/opencode-plugin.ts b/templates/opencode-plugin.ts index ab2295f..e6f8c71 100644 --- a/templates/opencode-plugin.ts +++ b/templates/opencode-plugin.ts @@ -105,6 +105,23 @@ export const LatPlugin: Plugin = async (ctx) => { return output || "No references found." }, }), + + lat_get_source: tool({ + description: + 'Return the active location for a configured external source handle', + args: { + externalSource: tool.schema.string( + 'External source handle (e.g. "architecture-docs")', + ), + }, + async execute(args, context) { + const output = tryRun( + ['get-source', args.externalSource], + projectRoot(context.directory, context.worktree), + ); + return output || 'External source not found.'; + }, + }), }, hooks: { diff --git a/templates/pi-extension.ts b/templates/pi-extension.ts index 0a066b6..077fa88 100644 --- a/templates/pi-extension.ts +++ b/templates/pi-extension.ts @@ -219,6 +219,32 @@ export default function (pi: ExtensionAPI) { }, }); + pi.registerTool({ + name: "lat_get_source", + label: "lat get source", + description: + "Return the active location for a configured external source handle", + promptSnippet: "Resolve an external source handle to its active location", + parameters: Type.Object({ + externalSource: Type.String({ + description: 'External source handle (e.g. "architecture-docs")', + }), + }), + async execute(_id, params) { + const output = tryRun(["get-source", JSON.stringify(params.externalSource)]); + return { + content: [{ type: "text", text: output || "External source not found." }], + }; + }, + renderCall(args, theme) { + return new Text( + theme.fg("toolTitle", theme.bold("lat get source ")) + + theme.fg("dim", `"${args.externalSource}"`), + 0, 0, + ); + }, + }); + // ── Message renderers ──────────────────────────────────────────── pi.registerMessageRenderer("lat-reminder", (message, { expanded }, theme) => { diff --git a/templates/skill/SKILL.md b/templates/skill/SKILL.md index 4208385..ea32922 100644 --- a/templates/skill/SKILL.md +++ b/templates/skill/SKILL.md @@ -15,6 +15,7 @@ This skill covers the syntax, structure rules, and conventions for writing `lat. `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. Good candidates for sections: + - Architecture decisions and their rationale - Domain concepts and business rules - API contracts and protocols @@ -22,6 +23,7 @@ Good candidates for sections: - Non-obvious constraints or invariants Bad candidates: + - Step-by-step code walkthroughs (the code itself is the walkthrough) - Auto-generated API docs (use tools for that) - Temporary notes or TODOs @@ -79,16 +81,89 @@ The parser validates [[parser#Wiki Links|wiki link syntax]]. Reference functions, classes, constants, and methods in source files: ```markdown -[[src/config.ts#getConfigDir]] — function -[[src/server.ts#App#listen]] — class method -[[lib/utils.py#parse_args]] — Python function -[[src/lib.rs#Greeter#greet]] — Rust impl method -[[src/app.go#Greeter#Greet]] — Go method -[[src/app.h#Greeter]] — C struct +[[src/config.ts#getConfigDir]] — function +[[src/server.ts#App#listen]] — class method +[[lib/utils.py#parse_args]] — Python function +[[src/lib.rs#Greeter#greet]] — Rust impl method +[[src/app.go#Greeter#Greet]] — Go method +[[src/app.h#Greeter]] — C struct ``` `lat check` validates that all targets exist. +### External source links + +Reference pinned external repositories through short handles defined in frontmatter: + +```markdown +[[architecture-docs:docs/system/request-flow.md#L123]] +``` + +Define canonical handles in `lat.md/lat.md` frontmatter: + +```yaml +--- +lat: + external-sources: + architecture-docs: + repo: https://example.com/architecture-docs.git + rev: v6.9 + browse: https://example.com/architecture-docs/tree/{path}?h={rev}#{fragment} +--- +``` + +Machine-local overrides belong in `lat.md/config.local.json` under the same `lat.external-sources` key path: + +```json +{ + "lat": { + "external-sources": { + "architecture-docs": { + "path": "/Users/alice/src/architecture-docs" + } + } + } +} +``` + +Only `path` is supported in `config.local.json`. It must point to a checkout of the same repository at the pinned `rev`. A leading `~/` is expanded to the current user's home directory. When present and valid, `lat` prefers the local path for navigation; otherwise it falls back to the canonical `browse` URL. + +Use `lat get-source ` or the `lat_get_source` tool to see which root location is currently active for a configured handle. + +If the user says `read for `, treat `` as an external source handle: resolve it with `lat_get_source`, then inspect the returned repo URL or local checkout for the requested material. + +When the user asks you to add an external source, update both places as needed: + +- `lat.md/lat.md` frontmatter for the canonical checked-in handle definition +- `lat.md/config.local.json` for the machine-local `path` override when the user supplied one + +For example, a prompt like `lat.md: add external reference config for architecture docs at v7.0 tag and configure local override pointing to ~/src/architecture-docs` should result in: + +```yaml +--- +lat: + external-sources: + architecture-docs: + repo: https://example.com/architecture-docs.git + rev: v7.0 + browse: https://example.com/architecture-docs/tree/{path}?h={rev}#{fragment} +--- +``` + +```json +{ + "lat": { + "external-sources": { + "architecture-docs": { + "path": "~/src/architecture-docs" + } + } + } +} +``` + +Preserve any existing frontmatter fields and existing external source entries; merge the new handle instead of replacing the whole map. + ## Code refs Tie source code back to `lat.md/` sections with `@lat:` comments: @@ -117,6 +192,7 @@ Describe tests as sections in `lat.md/` files. Add frontmatter to require that e lat: require-code-mention: true --- + # Tests Authentication test specifications. @@ -143,6 +219,7 @@ def test_rejects_expired_tokens(): ``` Rules: + - Every leaf section under `require-code-mention: true` must be referenced by exactly one `@lat:` comment - Every section MUST have a description — at least one sentence explaining what the test verifies and why - `lat check` flags unreferenced specs and dangling code refs @@ -158,12 +235,18 @@ lat: --- ``` -Currently the only supported field is `require-code-mention` for test spec enforcement. +Supported fields: + +- `require-code-mention` — require each leaf section in the file to be referenced by code +- `external-sources` — define canonical external source handles in the root `lat.md/lat.md` file ## Validation Always run `lat check` after editing `lat.md/` files. It validates: + - All wiki links point to existing sections or source code symbols +- All configured external source handles are well-formed +- Any local external source override path points to a git checkout at the pinned revision - All `@lat:` code refs point to existing sections - Every section has a leading paragraph (≤250 chars) - All `require-code-mention` leaf sections are referenced in code diff --git a/tests/cases/external-project/lat.md/docs.md b/tests/cases/external-project/lat.md/docs.md new file mode 100644 index 0000000..fb51a9e --- /dev/null +++ b/tests/cases/external-project/lat.md/docs.md @@ -0,0 +1,5 @@ +# Docs + +Fixture docs used to exercise external-source lookup in MCP tests. + +See [[architecture-docs:docs/system/request-flow.md#L123]] for the linked design note. diff --git a/tests/cases/external-project/lat.md/lat.md b/tests/cases/external-project/lat.md/lat.md new file mode 100644 index 0000000..3b0fcce --- /dev/null +++ b/tests/cases/external-project/lat.md/lat.md @@ -0,0 +1,14 @@ +--- +lat: + external-sources: + architecture-docs: + repo: https://example.com/architecture-docs.git + rev: v6.9 + browse: https://example.com/architecture-docs/tree/{path}?h={rev}#{fragment} +--- + +# lat.md + +Fixture project with an external source handle for MCP tests. + +- [[docs]] — Example docs that reference the configured external source. diff --git a/tests/external-sources.test.ts b/tests/external-sources.test.ts new file mode 100644 index 0000000..c1cf3cf --- /dev/null +++ b/tests/external-sources.test.ts @@ -0,0 +1,411 @@ +import { describe, it, expect } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; +import { execSync } from 'node:child_process'; +import { plainStyler, type CmdContext } from '../src/context.js'; +import { checkCodeRefs, checkIndex, checkMd } from '../src/cli/check.js'; +import { findRefs } from '../src/cli/refs.js'; +import { expandPrompt } from '../src/cli/expand.js'; +import { getSourceCommand } from '../src/cli/get-source.js'; +import { formatSectionOutput, getSection } from '../src/cli/section.js'; + +function commitRepo(dir: string, message: string): void { + execSync('git add .', { cwd: dir, stdio: 'ignore' }); + execSync( + `git -c user.name=test -c user.email=test@example.com commit -m ${JSON.stringify(message)}`, + { + cwd: dir, + stdio: 'ignore', + }, + ); +} + +function makeProject( + opts: { + rev?: string; + localPath?: string; + includeLocalConfig?: boolean; + } = {}, +): string { + const root = mkdtempSync(join(tmpdir(), 'lat-external-project-')); + const latDir = join(root, 'lat.md'); + mkdirSync(latDir, { recursive: true }); + + writeFileSync(join(latDir, '.gitignore'), 'config.local.json\n'); + writeFileSync( + join(latDir, 'lat.md'), + `--- +lat: + external-sources: + architecture-docs: + repo: https://example.com/architecture-docs.git + rev: ${opts.rev ?? 'v6.9'} + browse: https://example.com/architecture-docs/tree/{path}?h={rev}#{fragment} +--- +# lat.md + +Directory index and canonical external source mappings for this fixture. + +- [[docs]] — Example docs that link to an external source. +`, + ); + writeFileSync( + join(latDir, 'docs.md'), + `# Docs + +Example section used to verify external-source resolution across commands. + +See [[architecture-docs:docs/system/request-flow.md#L123]] for the referenced design note. +`, + ); + + if (opts.includeLocalConfig || opts.localPath) { + writeFileSync( + join(latDir, 'config.local.json'), + JSON.stringify( + { + lat: { + 'external-sources': { + 'architecture-docs': opts.localPath + ? { path: opts.localPath } + : {}, + }, + }, + }, + null, + 2, + ) + '\n', + ); + } + + return root; +} + +function makeExternalRepo( + opts: { secondCommit?: boolean; baseDir?: string } = {}, +): { + dir: string; + rev: string; +} { + const dir = mkdtempSync(join(opts.baseDir ?? tmpdir(), 'lat-external-repo-')); + mkdirSync(join(dir, 'docs', 'system'), { recursive: true }); + writeFileSync( + join(dir, 'docs', 'system', 'request-flow.md'), + 'Request flow architecture notes.\n', + ); + + execSync('git init', { cwd: dir, stdio: 'ignore' }); + commitRepo(dir, 'init'); + const rev = execSync('git rev-parse HEAD', { + cwd: dir, + encoding: 'utf-8', + }).trim(); + + if (opts.secondCommit) { + writeFileSync( + join(dir, 'docs', 'system', 'request-flow.md'), + 'Updated request flow architecture notes.\n', + ); + commitRepo(dir, 'update'); + } + + return { dir, rev }; +} + +function makeAsciiDocRepo(): { + dir: string; + rev: string; +} { + const dir = mkdtempSync(join(tmpdir(), 'lat-external-adoc-')); + mkdirSync(join(dir, 'design', 'XFS_Filesystem_Structure'), { + recursive: true, + }); + writeFileSync( + join( + dir, + 'design', + 'XFS_Filesystem_Structure', + 'extended_attributes.asciidoc', + ), + `= XFS Filesystem Structure\n\nIntro.\n\n== Extended Attributes\n\nLayout details.\n\n[#custom-layout]\n== Custom Layout\n\nCustom layout details.\n\n== Remote Attributes\n\nRemote details.\n`, + ); + + execSync('git init', { cwd: dir, stdio: 'ignore' }); + commitRepo(dir, 'init'); + + const rev = execSync('git rev-parse HEAD', { + cwd: dir, + encoding: 'utf-8', + }).trim(); + return { dir, rev }; +} + +function ctxFor(root: string): CmdContext { + return { + latDir: join(root, 'lat.md'), + projectRoot: root, + styler: plainStyler, + mode: 'cli', + }; +} + +function writeExternalCodeRef( + root: string, + target = 'architecture-docs:docs/system/request-flow.md#L123', +): void { + const marker = '@lat:'; + mkdirSync(join(root, 'src'), { recursive: true }); + writeFileSync( + join(root, 'src', 'app.ts'), + `// ${marker} [[${target}]]\nexport const answer = 42;\n`, + ); +} + +function writeDocsRef( + root: string, + target = 'architecture-docs:docs/system/request-flow.md#L123', +): void { + writeFileSync( + join(root, 'lat.md', 'docs.md'), + `# Docs\n\nExample section used to verify external-source resolution across commands.\n\nSee [[${target}]] for the referenced design note.\n`, + ); +} + +describe('external sources', () => { + // @lat: [[external-sources#Check md accepts configured external refs]] + it('check md accepts configured external refs', async () => { + const root = makeProject({ includeLocalConfig: true }); + try { + const { errors } = await checkMd(join(root, 'lat.md')); + expect(errors).toHaveLength(0); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + // @lat: [[external-sources#Check code-refs accepts configured external refs]] + it('check code-refs accepts configured external refs in source comments', async () => { + const root = makeProject(); + writeExternalCodeRef(root); + try { + const { errors, files } = await checkCodeRefs(join(root, 'lat.md')); + expect(errors).toHaveLength(0); + expect(files).toEqual({ '.ts': 1 }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + // @lat: [[external-sources#Index allows config.local.json]] + it('check index ignores config.local.json inside lat.md', async () => { + const root = makeProject({ includeLocalConfig: true }); + try { + const errors = await checkIndex(join(root, 'lat.md')); + expect(errors).toHaveLength(0); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + // @lat: [[external-sources#Refs finds markdown backlinks for external target]] + it('finds markdown backlinks for an external target', async () => { + const root = makeProject(); + try { + const result = await findRefs( + ctxFor(root), + 'architecture-docs:docs/system/request-flow.md#L123', + 'md', + ); + expect(result.kind).toBe('found'); + if (result.kind === 'found') { + expect(result.mdRefs).toHaveLength(1); + expect(result.mdRefs[0].section.id).toBe('lat.md/docs#Docs'); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + // @lat: [[external-sources#Refs finds code backlinks for external target]] + it('finds code backlinks for an external target', async () => { + const root = makeProject(); + writeExternalCodeRef(root); + try { + const result = await findRefs( + ctxFor(root), + 'architecture-docs:docs/system/request-flow.md#L123', + 'code', + ); + expect(result.kind).toBe('found'); + if (result.kind === 'found') { + expect(result.mdRefs).toHaveLength(0); + expect(result.codeRefs).toHaveLength(1); + expect(result.codeRefs[0]).toMatch(/src\/app\.ts:1$/); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + // @lat: [[external-sources#Section output renders external destination]] + it('renders local external destinations in section output when pinned repo is available', async () => { + const repo = makeExternalRepo(); + const root = makeProject({ rev: repo.rev, localPath: repo.dir }); + try { + const result = await getSection(ctxFor(root), 'lat.md/docs#Docs'); + expect(result.kind).toBe('found'); + if (result.kind === 'found') { + expect(result.outgoingExternalRefs).toHaveLength(1); + expect(result.outgoingExternalRefs[0].activeKind).toBe('local'); + const output = formatSectionOutput(ctxFor(root), result); + expect(output).toContain('file://'); + expect(output).toContain('docs/system/request-flow.md:123'); + } + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(repo.dir, { recursive: true, force: true }); + } + }); + + // @lat: [[external-sources#Section output resolves autogenerated AsciiDoc heading ids]] + it('resolves autogenerated AsciiDoc heading ids to local line ranges', async () => { + const repo = makeAsciiDocRepo(); + const root = makeProject({ rev: repo.rev, localPath: repo.dir }); + writeDocsRef( + root, + 'architecture-docs:design/XFS_Filesystem_Structure/extended_attributes.asciidoc#_extended_attributes', + ); + try { + const result = await getSection(ctxFor(root), 'lat.md/docs#Docs'); + expect(result.kind).toBe('found'); + if (result.kind === 'found') { + expect(result.outgoingExternalRefs).toHaveLength(1); + expect(result.outgoingExternalRefs[0].line).toBe(5); + expect(result.outgoingExternalRefs[0].endLine).toBe(9); + const output = formatSectionOutput(ctxFor(root), result); + expect(output).toContain('extended_attributes.asciidoc:5-9'); + } + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(repo.dir, { recursive: true, force: true }); + } + }); + + // @lat: [[external-sources#Expand resolves explicit AsciiDoc heading ids]] + it('resolves explicit AsciiDoc heading ids during prompt expansion', async () => { + const repo = makeAsciiDocRepo(); + const root = makeProject({ rev: repo.rev, localPath: repo.dir }); + try { + const output = await expandPrompt( + ctxFor(root), + 'Inspect [[architecture-docs:design/XFS_Filesystem_Structure/extended_attributes.asciidoc#custom-layout]]', + ); + expect(output).toContain('external source architecture-docs'); + expect(output).toContain('extended_attributes.asciidoc:10-13'); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(repo.dir, { recursive: true, force: true }); + } + }); + + // @lat: [[external-sources#Tilde path expands to home directory]] + it('expands ~ in local external source paths', async () => { + const home = homedir(); + const repo = makeExternalRepo({ baseDir: home }); + const tildePath = `~/${relative(home, repo.dir)}`; + const root = makeProject({ rev: repo.rev, localPath: tildePath }); + try { + const { errors } = await checkMd(join(root, 'lat.md')); + expect(errors).toHaveLength(0); + + const result = await getSection(ctxFor(root), 'lat.md/docs#Docs'); + expect(result.kind).toBe('found'); + if (result.kind === 'found') { + expect(result.outgoingExternalRefs[0].activeKind).toBe('local'); + expect(result.outgoingExternalRefs[0].localPath).toContain( + 'docs/system/request-flow.md', + ); + } + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(repo.dir, { recursive: true, force: true }); + } + }); + + // @lat: [[external-sources#Expand includes external context]] + it('adds external-source context during prompt expansion', async () => { + const root = makeProject(); + try { + const output = await expandPrompt( + ctxFor(root), + 'Inspect [[architecture-docs:docs/system/request-flow.md#L123]]', + ); + expect(output).toContain('external source architecture-docs'); + expect(output).toContain( + 'https://example.com/architecture-docs/tree/docs/system/request-flow.md?h=v6.9#L123', + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + // @lat: [[external-sources#Check md reports stale local external revision]] + it('reports stale local external revisions', async () => { + const repo = makeExternalRepo({ secondCommit: true }); + const root = makeProject({ rev: repo.rev, localPath: repo.dir }); + try { + const { errors } = await checkMd(join(root, 'lat.md')); + expect(errors.some((err) => err.message.includes('expected'))).toBe(true); + expect( + errors.some((err) => + err.message.includes('external source "architecture-docs"'), + ), + ).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(repo.dir, { recursive: true, force: true }); + } + }); + + // @lat: [[external-sources#Check md can ignore local overrides]] + it('can ignore local external overrides during check', async () => { + const repo = makeExternalRepo({ secondCommit: true }); + const root = makeProject({ rev: repo.rev, localPath: repo.dir }); + try { + const { errors } = await checkMd(join(root, 'lat.md'), { + ignoreLocalOverrides: true, + }); + expect(errors).toHaveLength(0); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(repo.dir, { recursive: true, force: true }); + } + }); + + // @lat: [[external-sources#Get source returns canonical repo URL]] + it('returns the canonical repo URL when no local override is active', async () => { + const root = makeProject(); + try { + const result = await getSourceCommand(ctxFor(root), 'architecture-docs'); + expect(result.output).toBe('https://example.com/architecture-docs.git'); + expect(result.isError).toBeUndefined(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + // @lat: [[external-sources#Get source returns local repo path]] + it('returns the local checkout path when a pinned override is active', async () => { + const repo = makeExternalRepo(); + const root = makeProject({ rev: repo.rev, localPath: repo.dir }); + try { + const result = await getSourceCommand(ctxFor(root), 'architecture-docs'); + expect(result.output).toBe(repo.dir); + expect(result.isError).toBeUndefined(); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(repo.dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 04a95a3..be329bf 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -41,6 +41,7 @@ describe('mcp', () => { expect(names).toEqual([ 'lat_check', 'lat_expand', + 'lat_get_source', 'lat_locate', 'lat_refs', 'lat_search', @@ -184,11 +185,20 @@ describe.skipIf(!canRunSearch)('mcp search (rag)', () => { // @lat: [[tests/mcp#lat_search returns no results message]] it('lat_search returns no results message when key is missing', async () => { // Spin up a separate MCP server without LAT_LLM_KEY and without XDG config + const env = Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ); + env.LAT_LLM_KEY = ''; + env.XDG_CONFIG_HOME = tmp; + delete env.LAT_LLM_KEY_FILE; + delete env.LAT_LLM_KEY_HELPER; const transport2 = new StdioClientTransport({ command: 'node', args: [cliPath, 'mcp'], cwd: tmp, - env: { ...process.env, LAT_LLM_KEY: '', XDG_CONFIG_HOME: tmp }, + env, }); const client2 = new Client({ name: 'test2', version: '0.1' }); await client2.connect(transport2); @@ -204,3 +214,32 @@ describe.skipIf(!canRunSearch)('mcp search (rag)', () => { await client2.close(); }); }); + +describe('mcp external sources', () => { + let client: Client; + + beforeAll(async () => { + const transport = new StdioClientTransport({ + command: 'node', + args: [cliPath, 'mcp'], + cwd: join(casesDir, 'external-project'), + }); + client = new Client({ name: 'test-external', version: '0.1' }); + await client.connect(transport); + }); + + afterAll(async () => { + await client.close(); + }); + + // @lat: [[tests/mcp#lat_get_source returns canonical repo URL]] + it('lat_get_source returns the canonical repo URL for a configured handle', async () => { + const result = await client.callTool({ + name: 'lat_get_source', + arguments: { externalSource: 'architecture-docs' }, + }); + const text = (result.content as { type: string; text: string }[])[0].text; + expect(text.trim()).toBe('https://example.com/architecture-docs.git'); + expect(result.isError).toBeFalsy(); + }); +});