diff --git a/.github/workflows/open-review.yml b/.github/workflows/open-review.yml index 0d393d1..e89b876 100644 --- a/.github/workflows/open-review.yml +++ b/.github/workflows/open-review.yml @@ -39,9 +39,14 @@ jobs: OR_LLM_API_KEY: ${{ secrets.OR_LLM_API_KEY }} GH_TOKEN: ${{ github.token }} run: | + # The container's git runs as root over the runner-owned checkout, so + # git's dubious-ownership guard rejects it ("Not a git repository") and + # open-review crashes before it can diff. Mark /repo safe via GIT_CONFIG_* + # env (applies to every git call inside the container, no config file). docker run --rm --env-file or.env \ -e OR_LLM_API_KEY -e GITHUB_TOKEN="$GH_TOKEN" \ -e GITHUB_ACTIONS=true -e GITHUB_BASE_REF="${{ github.base_ref }}" \ + -e GIT_CONFIG_COUNT=1 -e GIT_CONFIG_KEY_0=safe.directory -e GIT_CONFIG_VALUE_0=/repo \ -v "$PWD":/repo -w /repo \ ghcr.io/manchtools/open-review:latest run # gate, sarif, provider pin, --emit-* etc. all come from OR_* env — no flags here. diff --git a/README.md b/README.md index c59a837..ad2471b 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ source with `docref: begin ` / `docref: end `, for sub-symbol slices and languages without addressable symbols, such as CSS). The code side has its own loud signal, not just the docs: - + `docref anchors` inventories every region marker and what references it, and a marker that nothing references is flagged **not used** — which fails `docref check` unless `[anchors] allow-unused = true`. A stranded marker, like a broken diff --git a/docs/01-getting-started/01-introduction.md b/docs/01-getting-started/01-introduction.md index eeca5ad..ae977fa 100644 --- a/docs/01-getting-started/01-introduction.md +++ b/docs/01-getting-started/01-introduction.md @@ -48,7 +48,7 @@ no marker needed) or a **region** (a span named in the source with `docref: begin ` / `docref: end `, for sub-symbol slices). The code side has its own loud signal, not just the docs: - + `docref anchors` inventories every region marker and what references it, and a marker that nothing references is flagged **not used**, which fails `docref check` unless `[anchors] allow-unused = true`. A stranded marker, like a diff --git a/docs/03-tooling/01-cli/03-cross-repo-update.md b/docs/03-tooling/01-cli/03-cross-repo-update.md index de1fa45..b572680 100644 --- a/docs/03-tooling/01-cli/03-cross-repo-update.md +++ b/docs/03-tooling/01-cli/03-cross-repo-update.md @@ -7,7 +7,7 @@ description: Advance cross-repo pins in docref.lock to a tracked branch's tip, r ## `docref update [alias...] [--check]` - + For each alias (default: all): fetch the tracked branch, advance `docref.lock` to its tip, refresh all snippets referencing the alias, and report every claim that became `stale-claim` under the new rev. diff --git a/docs/03-tooling/01-cli/05-ls-and-anchors.md b/docs/03-tooling/01-cli/05-ls-and-anchors.md index 5277971..bf821b2 100644 --- a/docs/03-tooling/01-cli/05-ls-and-anchors.md +++ b/docs/03-tooling/01-cli/05-ls-and-anchors.md @@ -15,7 +15,7 @@ Dump the reverse index: every referenced anchor and everything referencing it. T ## `docref anchors [--json]` - + The code-side inventory, the reverse of `ls`: scan the source tree for every declared region marker and list each with the references to it; an anchor with none is flagged **not used**. Marker errors (duplicate names, unmatched begin/end) surface here even in files nothing references, which `check` alone would never visit. Exit `2` on marker errors, `0` otherwise (an unused anchor is information, not a failure). diff --git a/docs/04-internals/04-parsing-a-reference.md b/docs/04-internals/04-parsing-a-reference.md index 174137f..d63ec12 100644 --- a/docs/04-internals/04-parsing-a-reference.md +++ b/docs/04-internals/04-parsing-a-reference.md @@ -61,14 +61,8 @@ export function parseRef(raw: string): Ref { Run from anywhere inside the repository: `docref` walks up to the nearest `docref.toml`, falling back to the working directory. -```ts docref=packages/core/src/config.ts#findRoot:371ad98d +```ts docref=packages/core/src/config.ts#findRoot:64be4c67 export function findRoot(cwd: string): string { - let dir = cwd; - for (;;) { - if (existsSync(join(dir, 'docref.toml'))) return dir; - const parent = dirname(dir); - if (parent === dir) return cwd; - dir = parent; - } + return findRootOrNull(cwd) ?? cwd; } ``` diff --git a/install.sh b/install.sh index 71ff003..22d2c0c 100755 --- a/install.sh +++ b/install.sh @@ -56,10 +56,10 @@ download_asset() { # select the asset object by name and read its API url as a unit — never # rely on name/url field adjacency (the url precedes the name in the JSON) url=$(printf '%s' "$release_json" | jq -r --arg n "$name" '.assets[] | select(.name==$n) | .url') - [ -n "$url" ] && [ "$url" != "null" ] || { + if [ -z "$url" ] || [ "$url" = "null" ]; then echo "docref: no asset '$name' in the latest release." >&2 exit 1 - } + fi curl -fSL -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/octet-stream" "$url" -o "$out" else curl -fSL "https://github.com/${repo}/releases/latest/download/${name}" -o "$out" @@ -106,7 +106,7 @@ trap - EXIT rm -f "$sums" echo "docref: installed to $dir/docref" -"$dir/docref" --version >/dev/null 2>&1 && echo "docref: $($dir/docref --version) ready — run 'docref check', update with 'docref self-update'." +"$dir/docref" --version >/dev/null 2>&1 && echo "docref: $("$dir/docref" --version) ready — run 'docref check', update with 'docref self-update'." case ":$PATH:" in *":$dir:"*) ;; diff --git a/packages/cli/src/selfupdate.test.ts b/packages/cli/src/selfupdate.test.ts index f02b673..d3fda8e 100644 --- a/packages/cli/src/selfupdate.test.ts +++ b/packages/cli/src/selfupdate.test.ts @@ -30,7 +30,13 @@ describe('assetName', () => { // Covers correct / absent / present-but-wrong for each step, including the // checksum gate that guards the binary swap. describe('selfUpdate: decision and failure paths', () => { - const presentAsset = assetName(process.platform, process.arch); + // A fixed supported platform, injected into the IO, keeps these tests + // deterministic and independent of the running platform — so collection never + // throws on an unsupported host (assetName evaluated at describe scope would + // crash the whole file on, e.g., FreeBSD). The real platform matrix is + // covered in the assetName describe above. + const [PLATFORM, ARCH] = ['linux', 'x64']; + const presentAsset = assetName(PLATFORM, ARCH); const BINARY = Buffer.from('new-binary'); const BIN_SHA = createHash('sha256').update(BINARY).digest('hex'); // a valid SHA256SUMS manifest matching BINARY for this platform's asset @@ -46,8 +52,8 @@ describe('selfUpdate: decision and failure paths', () => { downloadAsset: async (asset) => (asset.name === 'SHA256SUMS' ? SUMS : BINARY), replaceBinary: () => {}, refreshExtension: async () => ({ code: 0, out: 'extension refreshed' }), - platform: process.platform, - arch: process.arch, + platform: PLATFORM, + arch: ARCH, execPath: '/fake/docref', ...over }); diff --git a/packages/cli/src/standalone.ts b/packages/cli/src/standalone.ts index 991f1df..6443f5f 100644 --- a/packages/cli/src/standalone.ts +++ b/packages/cli/src/standalone.ts @@ -24,7 +24,10 @@ import phpWasm from 'tree-sitter-wasms/out/tree-sitter-php.wasm' with { type: 'f import swiftWasm from 'tree-sitter-wasms/out/tree-sitter-swift.wasm' with { type: 'file' }; import kotlinWasm from 'tree-sitter-wasms/out/tree-sitter-kotlin.wasm' with { type: 'file' }; import scalaWasm from 'tree-sitter-wasms/out/tree-sitter-scala.wasm' with { type: 'file' }; -import bashWasm from 'tree-sitter-wasms/out/tree-sitter-bash.wasm' with { type: 'file' }; +// bash and proto are vendored (source-built): tree-sitter-wasms ships no proto, +// and its bash build imports a libc symbol the runtime lacks, crashing the +// external scanner (issue #1) — the vendored copy resolves it. +import bashWasm from '@open-docref/core/grammars/tree-sitter-bash.wasm' with { type: 'file' }; import protoWasm from '@open-docref/core/grammars/tree-sitter-proto.wasm' with { type: 'file' }; import { configureWasm } from '@open-docref/core'; import { run, VERSION } from './main'; diff --git a/packages/core/grammars/tree-sitter-bash.wasm b/packages/core/grammars/tree-sitter-bash.wasm new file mode 100755 index 0000000..b7f654d Binary files /dev/null and b/packages/core/grammars/tree-sitter-bash.wasm differ diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index a3bcf08..1247ee5 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; -import { loadProject, findRoot } from './config'; +import { loadProject, findRoot, findRootOrNull } from './config'; import { DocrefError } from './errors'; import { tmp, write } from './testutil'; @@ -123,4 +123,19 @@ describe('findRoot: locating the project root', () => { // (parent === dir) and returns the original cwd, never looping forever expect(findRoot(nested)).toBe(nested); }); + + it('findRootOrNull returns the governing root, or null when there is none', () => { + // the governing-root rule the extension needs: a file belongs to the + // nearest docref.toml above it, independent of any workspace folder. In a + // multi-repo layout each sibling resolves to its OWN root, not a shared one. + const ws = tmp(); // the "workspace folder" — no docref.toml of its own + write(ws, 'sdk/docref.toml', ''); + write(ws, 'docs/docref.toml', ''); + mkdirSync(join(ws, 'sdk', 'pkg'), { recursive: true }); + mkdirSync(join(ws, 'docs', 'content'), { recursive: true }); + expect(findRootOrNull(join(ws, 'sdk', 'pkg'))).toBe(join(ws, 'sdk')); + expect(findRootOrNull(join(ws, 'docs', 'content'))).toBe(join(ws, 'docs')); + // the workspace folder itself governs nothing (no docref.toml at or above) + expect(findRootOrNull(ws)).toBeNull(); + }); }); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index acfd241..a6d7060 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -125,13 +125,23 @@ export function appendRepoBlock(root: string, alias: string, url: string, ref?: writeFileSync(path, prefix + block + '\n'); } -/** Walk up from cwd to the nearest docref.toml; fall back to cwd itself. */ -export function findRoot(cwd: string): string { - let dir = cwd; +/** + * Walk up from `from` to the nearest directory containing docref.toml, or null + * if none is found before the filesystem root. This is the governing-root rule + * (ESLint/EditorConfig style): a file's project is the closest docref.toml at or + * above it, independent of any editor's workspace folder. + */ +export function findRootOrNull(from: string): string | null { + let dir = from; for (;;) { if (existsSync(join(dir, 'docref.toml'))) return dir; const parent = dirname(dir); - if (parent === dir) return cwd; + if (parent === dir) return null; dir = parent; } } + +/** Walk up from cwd to the nearest docref.toml; fall back to cwd itself. */ +export function findRoot(cwd: string): string { + return findRootOrNull(cwd) ?? cwd; +} diff --git a/packages/core/src/gitcache.test.ts b/packages/core/src/gitcache.test.ts index 8a8eb9f..41469ed 100644 --- a/packages/core/src/gitcache.test.ts +++ b/packages/core/src/gitcache.test.ts @@ -105,6 +105,14 @@ describe('assertUrl: a remote url safe to hand git', () => { expect(() => assertUrl('--upload-pack=touch /tmp/pwned')).toThrow(DocrefError); }); + it('rejects an scp-like url whose host or path is option-shaped (ssh -oProxyCommand RCE)', () => { + // host starting with '-' becomes an ssh option (CVE-2017-1000117 class) + expect(() => assertUrl('git@-oProxyCommand=touch /tmp/pwned:repo.git')).toThrow(DocrefError); + // path starting with '-' can reach the remote git as an option + expect(() => assertUrl('git@github.com:--upload-pack=touch /tmp/pwned')).toThrow(DocrefError); + expect(() => assertUrl('git@github.com:-x')).toThrow(DocrefError); + }); + it('rejects empty and unknown/no scheme', () => { expect(() => assertUrl('')).toThrow(DocrefError); expect(() => assertUrl('javascript:alert(1)')).toThrow(DocrefError); diff --git a/packages/core/src/gitcache.ts b/packages/core/src/gitcache.ts index 4a1bc5b..9e95fad 100644 --- a/packages/core/src/gitcache.ts +++ b/packages/core/src/gitcache.ts @@ -43,7 +43,12 @@ export function cacheDirFor(url: string): string { const REV = /^[0-9a-f]{7,64}$/i; const REF = /^[0-9A-Za-z._/-]+$/; const URL_SCHEME = /^(https?|ssh|git|file):\/\//i; -const SCP_LIKE = /^[^\s/:-][^\s/:]*@[^\s/:]+:.+$/; +// scp-like `user@host:path`. The first char of user, host, AND path all exclude +// '-': git hands the host to ssh, where a leading-dash host is parsed as an +// option (`-oProxyCommand=`, the CVE-2017-1000117 RCE class), and a +// leading-dash path can reach the remote git as an option. The path also forbids +// whitespace so a `host:--flag ` payload cannot smuggle a second token. +const SCP_LIKE = /^[^\s/:-][^\s/:]*@[^\s/:-][^\s/:]*:[^\s-][^\s]*$/; export function assertRev(rev: string): string { if (typeof rev !== 'string' || !REV.test(rev)) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 83bc315..5d1e04f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,7 +18,7 @@ export { export { listDeclarations, findSymbol, configureWasm, type Decl, type WasmConfig } from './symbols'; export { languageForFile, fenceLanguageForRef, type LanguageId, type LanguageInfo } from './languages'; export { workingTreeSource, resolveAnchor, type FileSource, type Anchor } from './resolve'; -export { loadProject, writeLock, findRoot, GATE_LEVELS, type Project, type RepoConfig, type GateLevel } from './config'; +export { loadProject, writeLock, findRoot, findRootOrNull, GATE_LEVELS, type Project, type RepoConfig, type GateLevel } from './config'; export { ensureCommit, branchTip, gitRevSource } from './gitcache'; export { check, diff --git a/packages/core/src/ops.test.ts b/packages/core/src/ops.test.ts index aefff4e..0890986 100644 --- a/packages/core/src/ops.test.ts +++ b/packages/core/src/ops.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { existsSync, rmSync } from 'node:fs'; +import { existsSync, rmSync, chmodSync } from 'node:fs'; import { join } from 'node:path'; import { loadProject } from './config'; import { check, refresh, approve, update, addRepo, affected, ls, anchors, diff, remove, exitCode, exitCodeFor, suggest, type Report } from './ops'; @@ -321,6 +321,56 @@ describe('affected: mapping changes to carriers', () => { }); }); +describe('path scoping: a directory argument (issue #2)', () => { + // A directory passed to a path-scoped op used to hit readFileSync and surface + // a raw EISDIR. It now expands to every scanned doc under it (the natural way + // to gate one section of a docs tree); a file is still taken verbatim. + // each doc carries one snippet ref to a missing file, so it surfaces as a + // broken entry keyed by its own doc path — the signal that proves which docs + // a given scope actually visited. + const brokenRef = (name: string) => ['```txt docref=' + name + '.txt#@x', '```', ''].join('\n'); + function tree(): string { + const root = tmp(); + write(root, 'content/01-intro/a.md', brokenRef('a')); + write(root, 'content/02-concepts/b.md', brokenRef('b')); + write(root, 'content/02-concepts/nested/c.md', brokenRef('c')); + write(root, 'other/d.md', brokenRef('d')); + return root; + } + const docsIn = (r: Report) => new Set(r.entries.map((e) => e.doc)); + + it('scans every doc under the directory instead of throwing EISDIR', async () => { + const root = tree(); + const report = await check(loadProject(root), ['content/02-concepts']); + // the directory (including its nested doc) is in scope; siblings are not + expect(docsIn(report)).toEqual( + new Set(['content/02-concepts/b.md', 'content/02-concepts/nested/c.md']) + ); + }); + + it('a trailing slash is tolerated and a nested directory scopes tighter', async () => { + const root = tree(); + expect(docsIn(await check(loadProject(root), ['content/02-concepts/']))).toEqual( + new Set(['content/02-concepts/b.md', 'content/02-concepts/nested/c.md']) + ); + expect(docsIn(await check(loadProject(root), ['content/02-concepts/nested']))).toEqual( + new Set(['content/02-concepts/nested/c.md']) + ); + }); + + it('still checks a single explicit file verbatim', async () => { + const root = tree(); + expect(docsIn(await check(loadProject(root), ['content/01-intro/a.md']))).toEqual( + new Set(['content/01-intro/a.md']) + ); + }); + + it('keeps a nonexistent path so the read fails loudly (ENOENT), not silently dropped', async () => { + const root = tree(); + await expect(check(loadProject(root), ['content/missing.md'])).rejects.toThrow(); + }); +}); + describe('anchors: the code-side inventory', () => { // The reverse view (tooling.md): every declared region marker in the // codebase, each flagged with its references or as not used. Symbols @@ -379,6 +429,24 @@ describe('anchors: the code-side inventory', () => { const result = await anchors(loadProject(root)); expect(result.anchors.map((a) => a.name)).toEqual(['mine']); }); + + it('lists markers inside dot-directories, so an unused one there is flagged', async () => { + // regression for issue #3: the code-side scan skipped dot-directories + // (.github/ especially) while the reference resolver read them by path, so + // a marker nothing references was never reported as unused — the fail-loud + // guarantee had a hole exactly where CI configs live. + const root = tmp(); + write( + root, + '.github/workflows/release.yml', + '# docref: begin release-trigger\non: push\n# docref: end release-trigger\n' + ); + const result = await anchors(loadProject(root)); + expect(result.errors).toEqual([]); + const ref = '.github/workflows/release.yml#@release-trigger'; + expect(result.anchors.map((a) => `${a.file}#@${a.name}`)).toContain(ref); + expect(result.anchors.find((a) => a.name === 'release-trigger')!.references).toEqual([]); + }); }); describe('unused anchors fail the gate', () => { @@ -798,6 +866,32 @@ describe('cross-repo: lock, update, pinned resolution', () => { expect(u2.report.summary.staleSnippet).toBe(0); }); + it('restores the in-memory lock when refresh fails mid-update', async () => { + // update advances project.lock in-memory before refresh and persists only + // after; if refresh throws, the on-disk lock stays put AND the in-memory + // lock is rolled back, so a reused Project is never left advanced past disk. + const { remote, root } = remoteFixture(); + await update(loadProject(root)); // establish a prior locked rev + const p = loadProject(root); + const priorRev = p.lock.lib!.rev; + const lockOnDisk = read(root, 'docref.lock'); + + // the remote moves, so the next update wants to refresh the snippet + write(remote, 'src/handler.go', 'package api\n\nfunc Verify() { /* v2 */ }\n'); + commitAll(remote, 'v2'); + // make the doc unwritable so refresh's writeFileSync throws mid-update + chmodSync(join(root, 'docs/x.md'), 0o444); + try { + await expect(update(p)).rejects.toThrow(); + // in-memory lock rolled back to the prior rev, not the new tip + expect(p.lock.lib!.rev).toBe(priorRev); + // and the on-disk lock was never advanced + expect(read(root, 'docref.lock')).toBe(lockOnDisk); + } finally { + chmodSync(join(root, 'docs/x.md'), 0o644); + } + }); + it('fails closed at load on a poisoned lock rev instead of running git on it', () => { const { remote, root } = remoteFixture(); // a malicious PR could ship a docref.lock whose rev is a git option; diff --git a/packages/core/src/ops.ts b/packages/core/src/ops.ts index 9a08435..e8ea990 100644 --- a/packages/core/src/ops.ts +++ b/packages/core/src/ops.ts @@ -3,7 +3,8 @@ // out of stale-snippet on its own (refresh), and never moves anything out // of stale-claim (approve is explicit) or broken (author intervention). import { execFileSync } from 'node:child_process'; -import { readFileSync, statSync, writeFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { stat } from 'node:fs/promises'; import { join } from 'node:path'; import { glob } from 'tinyglobby'; import type { Project, GateLevel } from './config'; @@ -111,8 +112,7 @@ function summarize(entries: ReportEntry[]): Report['summary'] { return summary; } -async function docFiles(project: Project, paths?: string[]): Promise { - if (paths?.length) return [...paths].sort(); +async function scannedDocs(project: Project): Promise { const found = await glob(project.scan.include, { cwd: project.root, ignore: project.scan.exclude @@ -120,6 +120,40 @@ async function docFiles(project: Project, paths?: string[]): Promise { return found.sort(); } +/** + * The docs a path-scoped op runs over. No paths → the whole scan set. A path + * argument that is a directory expands to every scanned doc under it (the + * natural way to gate one section of a docs tree, and what agents/CI pass + * reflexively); a file is taken verbatim, even outside the scan scope, so a + * single explicit file still checks. A path that is neither is kept as-is so + * the downstream read fails loudly (ENOENT) instead of being silently dropped. + */ +async function docFiles(project: Project, paths?: string[]): Promise { + if (!paths?.length) return scannedDocs(project); + let scanned: string[] | null = null; + const out = new Set(); + for (const p of paths) { + let isDir = false; + try { + isDir = (await stat(join(project.root, p))).isDirectory(); + } catch { + // stat failed (missing path, or a permission/symlink error): keep the arg + // so the downstream read surfaces the real error, not a silent drop + } + if (isDir) { + scanned ??= await scannedDocs(project); + // scanned paths are POSIX (tinyglobby forward slashes); normalize the + // arg's separators too so a Windows `content\dir` still matches instead + // of silently expanding to nothing + const prefix = p.replace(/\\/g, '/').replace(/\/+$/, '') + '/'; + for (const f of scanned) if (f.startsWith(prefix)) out.add(f); + } else { + out.add(p); + } + } + return [...out].sort(); +} + type RevOverrides = Record; /** Lazily build one FileSource per (alias, rev); same-repo = working tree. */ @@ -412,9 +446,33 @@ export async function update( }; } - for (const [alias, rev] of Object.entries(overrides)) project.lock[alias] = { rev }; - writeLock(project); - const { report } = await refresh(project); + // Advance the lock in-memory and refresh snippets against the new revs + // FIRST; persist the lock only once refresh succeeds, so a refresh failure + // (IO error, or a resolve throw) never leaves docref.lock advanced past + // documents that still hold the old content. On failure, restore the prior + // in-memory lock too, so a reused Project is not left advanced past disk + // (symmetry with addRepo's project.repos rollback). + const priorLock = new Map(); + for (const [alias, rev] of Object.entries(overrides)) { + priorLock.set(alias, project.lock[alias]); + project.lock[alias] = { rev }; + } + // writeLock is inside the guarded region too: if persisting the lock fails + // (disk full, permissions) the in-memory lock is rolled back to match the + // unchanged file, so in-memory and on-disk never disagree. (The refreshed + // docs remain on disk, but that is visible, recoverable drift — check flags + // it, and re-running update is idempotent.) + let report: Report; + try { + report = (await refresh(project)).report; + writeLock(project); + } catch (e) { + for (const [alias, prev] of priorLock) { + if (prev) project.lock[alias] = prev; + else delete project.lock[alias]; + } + throw e; + } return { changed, report }; } @@ -442,9 +500,24 @@ export async function addRepo( assertUrl(url); if (ref !== undefined) assertRef(ref); - appendRepoBlock(project.root, alias, url, ref); - // reflect it in-memory so update() sees the new alias, then lock its tip + // Reflect the alias in-memory so update() sees it. Order the persistence so no + // two-file inconsistency can survive a failure: + // 1. checkOnly update — fetch the tip to prove the remote is reachable, + // WITHOUT writing anything (an unfetchable repo throws here, before any + // file is touched); + // 2. append the [repos.] block to docref.toml; + // 3. real update — write the lock and refresh. + // A failure in 1 or 2 rolls the in-memory alias back and persists nothing. A + // failure in 3 leaves a declared-but-unlocked alias (recoverable via + // `docref update`), never a lock entry with no toml declaration. project.repos[alias] = { url, ...(ref ? { ref } : {}) }; + try { + await update(project, { aliases: [alias], checkOnly: true }); + appendRepoBlock(project.root, alias, url, ref); + } catch (e) { + delete project.repos[alias]; + throw e; + } await update(project, { aliases: [alias] }); return { alias, url, ...(ref ? { ref } : {}), rev: project.lock[alias]!.rev }; } @@ -778,7 +851,12 @@ function refsOf(c: Reference): string[] { export async function anchorFiles(project: Project): Promise { let files = await glob(project.anchors.include, { cwd: project.root, - ignore: project.anchors.exclude + ignore: project.anchors.exclude, + // walk dot-directories (.github/ especially): markers live in workflow + // files and the reference resolver reads them by path, so the code-side + // inventory must see them too or unused-marker detection has a blind spot + // exactly where CI configs live (issue #3) + dot: true }); // in a git repo, respect gitignore: build outputs and generated trees // carry marker COPIES that would otherwise show up as anchors @@ -827,7 +905,8 @@ export async function anchors(project: Project): Promise { const abs = join(project.root, file); let text: string; try { - if (!statSync(abs).isFile() || statSync(abs).size > 2_000_000) continue; + const st = await stat(abs); // one stat, not two; async so the loop never blocks the event loop + if (!st.isFile() || st.size > 2_000_000) continue; text = readFileSync(abs, 'utf8'); } catch { continue; diff --git a/packages/core/src/symbols.test.ts b/packages/core/src/symbols.test.ts index 0d78176..182966c 100644 --- a/packages/core/src/symbols.test.ts +++ b/packages/core/src/symbols.test.ts @@ -13,7 +13,11 @@ const code = async (e: () => Promise): Promise => { await e(); return 'no-error'; } catch (err) { - return (err as DocrefError).code; + // only a classified DocrefError carries a `.code`; re-throw anything else + // (a TypeError, a wasm crash) so it fails the test loudly instead of + // returning undefined and masking the real failure + if (err instanceof DocrefError) return err.code; + throw err; } }; @@ -769,3 +773,22 @@ describe('symbol resolution across popular languages', () => { }); } }); + +describe('bash: constructs that reach the external scanner', () => { + // Regression for issue #1: the tree-sitter-wasms bash build imported a libc + // symbol (isalpha) no web-tree-sitter runtime exports, so any script whose + // parse reached the external scanner — a `[ … ]` test, extglob, or $'…' — + // crashed with a raw "resolved is not a function" instead of resolving. The + // vendored, source-built grammar resolves the import; these parse cleanly. + const CONSTRUCTS: { name: string; src: string }[] = [ + { name: 'test bracket', src: 'setup() {\n [ -f /etc/hosts ] && echo ok\n}\n' }, + { name: 'extglob', src: 'setup() {\n case $x in\n ?(a|b)) echo m ;;\n esac\n}\n' }, + { name: 'ansi-c quoting', src: "setup() {\n printf $'\\n\\t'\n}\n" } + ]; + for (const c of CONSTRUCTS) { + it(`resolves a function containing a ${c.name} without crashing`, async () => { + const d = await findSymbol(c.src, 'setup.sh', 'setup'); + expect(d.content).toContain('setup()'); + }); + } +}); diff --git a/packages/core/src/symbols.ts b/packages/core/src/symbols.ts index 311ad88..8a5e4af 100644 --- a/packages/core/src/symbols.ts +++ b/packages/core/src/symbols.ts @@ -79,7 +79,10 @@ function runtimeWasmPath(name: string): string { // dir, exposed via the package's "./grammars/*" export. They resolve through // node module resolution exactly like tree-sitter-wasms, so the same lookup // works from source, from a dependent's node_modules, and via self-reference. -const VENDORED_GRAMMARS = new Set(['proto']); +// bash is vendored not because tree-sitter-wasms omits it, but because its +// shipped build imports a libc symbol (isalpha) the runtime does not export, +// crashing the external scanner (issue #1); the source-built copy resolves it. +const VENDORED_GRAMMARS = new Set(['proto', 'bash']); function grammarWasmPath(wasm: string): string { const file = `tree-sitter-${wasm}.wasm`; diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 4d21c91..9a7e1d7 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -3,10 +3,10 @@ // this file only moves data between the core and the editor. import * as vscode from 'vscode'; import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, dirname } from 'node:path'; import { configureWasm, - findRoot, + findRootOrNull, loadProject, parseRef, workingTreeSource, @@ -51,9 +51,10 @@ import { buildReferencesTree, buildAnchorsTree, buildStageTree, + mergeProjectTrees, + aggregateStatusText, isRelevantChange, relPath, - statusText, refCompletionContext, pathCompletionsFromFiles, type Leader, @@ -144,18 +145,69 @@ function fenceLang(ref: string, fallback = ''): string { } } -let report: Report | null = null; -let index: RefIndex | null = null; -let anchorIndex: AnchorsResult | null = null; -let anchorFileList: string[] = []; // the [anchors] scope, for path completion +// One project's scan result. A workspace holds one of these per docref.toml +// (one in the common single-repo case, several in a multi-repo workspace), so +// every doc is evaluated against its OWN governing config rather than a single +// workspace-folder project. +type Scan = { + root: string; + project: Project; + report: Report; + index: RefIndex; + anchorIndex: AnchorsResult; + anchorFileList: string[]; // the [anchors] scope, for path completion + refPaths: Set; // same-repo ref target files (project-relative) + anchorFileSet: Set; // declared-anchor files (project-relative) +}; +let scans: Scan[] = []; + +function scanForRoot(root: string): Scan | undefined { + return scans.find((s) => s.root === root); +} + +function workspaceFolders(): string[] { + return (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); +} + +/** + * The governing project for a file: the nearest docref.toml at or above it + * (ESLint/EditorConfig style), independent of any workspace folder. This is the + * fix for multi-repo workspaces — a doc under sdk/ resolves against sdk/'s + * docref.toml (its aliases, its relative paths), not the workspace folder that + * may hold no config at all. + */ +function projectFor(fsPath: string): { project: Project; root: string } | null { + const root = findRootOrNull(dirname(fsPath)); + return root ? { project: loadProject(root), root } : null; +} -function workspaceRoot(): string | null { - return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? null; +/** The governing project of the active editor's document, if it has one. */ +function activeProject(): { project: Project; root: string } | null { + const f = vscode.window.activeTextEditor?.document.uri.fsPath; + return f ? projectFor(f) : null; } -function project(): Project | null { - const ws = workspaceRoot(); - return ws ? loadProject(findRoot(ws)) : null; +/** A tree node's own project (root stamped at build time), falling back to the + * active editor's project when the node carries none. */ +function projectForNode(n: { root?: string } | undefined): { project: Project; root: string } | null { + return n?.root ? { project: loadProject(n.root), root: n.root } : activeProject(); +} + +/** + * Every project root reachable from the workspace: each folder that is itself + * inside a project (walk up), plus every docref.toml discovered beneath the + * folders. In a single-repo workspace this is one root; in a multi-repo one it + * is each sibling repo's root, so each is scanned against its own config. + */ +async function discoverRoots(): Promise { + const roots = new Set(); + for (const ws of workspaceFolders()) { + const up = findRootOrNull(ws); + if (up) roots.add(up); + } + const found = await vscode.workspace.findFiles('**/docref.toml', '**/node_modules/**'); + for (const uri of found) roots.add(dirname(uri.fsPath)); + return [...roots]; } const STATE_ICONS: Record = { @@ -198,19 +250,22 @@ class SidebarTree implements vscode.TreeDataProvider, vscode.Dispos break; case 'issue': item.iconPath = new vscode.ThemeIcon(n.severity === 'error' ? 'error' : 'warning'); - item.command = { command: 'docref.openLocation', title: 'Open', arguments: [n.doc, n.line] }; + if (n.root && n.doc) + item.command = { command: 'docref.openLocation', title: 'Open', arguments: [join(n.root, n.doc), n.line] }; break; case 'file': item.iconPath = new vscode.ThemeIcon('file-code'); - item.command = { command: 'docref.openLocation', title: 'Open', arguments: [n.path, 1] }; + if (n.root && n.path) + item.command = { command: 'docref.openLocation', title: 'Open', arguments: [join(n.root, n.path), 1] }; break; case 'ref': item.iconPath = STATE_ICONS[n.state ?? 'unknown']; - item.command = { command: 'docref.openAnchor', title: 'Open code', arguments: [n.ref] }; + item.command = { command: 'docref.openAnchor', title: 'Open code', arguments: [n.ref, n.root] }; break; case 'location': item.iconPath = new vscode.ThemeIcon(n.kind === 'claim' ? 'note' : 'code'); - item.command = { command: 'docref.openLocation', title: 'Open', arguments: [n.doc, n.line] }; + if (n.root && n.doc) + item.command = { command: 'docref.openLocation', title: 'Open', arguments: [join(n.root, n.doc), n.line] }; break; } return item; @@ -229,12 +284,17 @@ export function activate(context: vscode.ExtensionContext): void { grammarsDir: join(context.extensionPath, 'dist', 'wasm') }); - // `${doc}:${line}` -> the ref and state under each docref squiggle, so the - // code-action provider can offer jump-to-code / diff without re-deriving from - // the report. Rebuilt on every rescan, in lockstep with the diagnostics. - let actionableAt = new Map(); + // `${absDoc}:${line}` -> the ref, state, and governing project root under each + // docref squiggle, so the code-action provider can offer jump-to-code / diff + // (and resolve against the right repo) without re-deriving from the report. + // Keyed by ABSOLUTE doc path so multi-repo docs never collide. Rebuilt on + // every rescan, in lockstep with the diagnostics. + let actionableAt = new Map(); // virtual documents backing the approved-vs-current drift diffs + // Evicted when a diff tab closes (onDidCloseTextDocument, in subscriptions), + // so the map stays bounded by the number of open drift diffs instead of + // growing for the whole session. const driftDocs = new Map(); let driftSeq = 0; const driftProvider: vscode.TextDocumentContentProvider = { @@ -249,10 +309,11 @@ export function activate(context: vscode.ExtensionContext): void { async function openDrift(opts?: { doc?: string; ref?: string; + root?: string; }): Promise<{ opened: number; total: number }> { - const p = project(); - if (!p) return { opened: 0, total: 0 }; - const all = (await diff(p, opts?.doc ? [opts.doc] : undefined)).entries; + const pr = opts?.root ? { project: loadProject(opts.root), root: opts.root } : activeProject(); + if (!pr) return { opened: 0, total: 0 }; + const all = (await diff(pr.project, opts?.doc ? [opts.doc] : undefined)).entries; const entries = opts?.ref ? all.filter((e) => e.ref === opts.ref) : all; let opened = 0; for (const e of entries) { @@ -280,8 +341,12 @@ export function activate(context: vscode.ExtensionContext): void { status.command = 'docrefRefs.focus'; status.text = 'docref'; status.show(); - const tree = new SidebarTree(() => (index ? buildReferencesTree(index, report) : [])); - const anchorTree = new SidebarTree(() => (anchorIndex ? buildAnchorsTree(anchorIndex) : [])); + const tree = new SidebarTree(() => + mergeProjectTrees(scans.map((s) => ({ root: s.root, nodes: buildReferencesTree(s.index, s.report) }))) + ); + const anchorTree = new SidebarTree(() => + mergeProjectTrees(scans.map((s) => ({ root: s.root, nodes: buildAnchorsTree(s.anchorIndex) }))) + ); // the staging area: refs collected (sha computed at stage time for // display, recomputed at insert time so pastes are always current) @@ -292,18 +357,20 @@ export function activate(context: vscode.ExtensionContext): void { stageTree.refresh(); } - async function resolveRefNow(refRaw: string): Promise<{ content: string; sha: string } | null> { - const p = project(); - if (!p) return null; + async function resolveRefNow( + refRaw: string, + pr: { project: Project } | null + ): Promise<{ content: string; sha: string } | null> { + if (!pr) return null; try { - return await resolveReference(p, refRaw); + return await resolveReference(pr.project, refRaw); } catch { return null; } } - async function stageAdd(ref: string): Promise { - const resolved = await resolveRefNow(ref); + async function stageAdd(ref: string, pr: { project: Project } | null): Promise { + const resolved = await resolveRefNow(ref, pr); staged = [ ...staged.filter((s) => s.ref !== ref), { ref, ...(resolved ? { sha: resolved.sha } : {}) } @@ -318,7 +385,7 @@ export function activate(context: vscode.ExtensionContext): void { void vscode.window.showWarningMessage('docref: open the target document and place the cursor first.'); return; } - const resolved = await resolveRefNow(n.ref); + const resolved = await resolveRefNow(n.ref, projectForNode(n)); if (!resolved) { void vscode.window.showWarningMessage(`docref: ${n.ref} does not resolve right now.`); return; @@ -334,57 +401,71 @@ export function activate(context: vscode.ExtensionContext): void { } let pending: NodeJS.Timeout | undefined; - // Derived from the indexes and only changing on rescan, so they are computed - // once here rather than rebuilt from the whole index on every '**/*' fs event - // (which fires constantly for build/.git churn). Named distinctly so they do - // not shadow the imported core `anchorFiles`. - let cachedRefPaths = new Set(); - let cachedAnchorFileSet = new Set(); async function rescan(): Promise { - const p = project(); - if (!p) return; + const roots = await discoverRoots(); + const next: Scan[] = []; try { - report = await check(p); - index = await ls(p); - anchorIndex = await anchors(p); - anchorFileList = await anchorFiles(p); + for (const root of roots) { + const project = loadProject(root); + const [report, index, anchorIndex, anchorFileList] = await Promise.all([ + check(project), + ls(project), + anchors(project), + anchorFiles(project) + ]); + next.push({ + root, + project, + report, + index, + anchorIndex, + anchorFileList, + refPaths: new Set( + index.refs + .map((r) => r.ref) + .filter((ref) => !ref.includes(':')) + .map((ref) => ref.split('#')[0]!) + ), + anchorFileSet: new Set(anchorIndex.anchors.map((a) => a.file)) + }); + } } catch (e) { void vscode.window.showErrorMessage(`docref: ${(e as Error).message}`); return; } - cachedRefPaths = new Set( - index.refs - .map((r) => r.ref) - .filter((ref) => !ref.includes(':')) - .map((ref) => ref.split('#')[0]!) - ); - cachedAnchorFileSet = new Set(anchorIndex.anchors.map((a) => a.file)); + scans = next; + + // diagnostics and the actionable-at lookup are keyed by ABSOLUTE path, so + // two projects that both hold a `docs/x.md` never collide diagnostics.clear(); actionableAt = new Map(); - const level = p.check.level; - for (const [doc, list] of diagnosticsFromReport(report, level)) { - for (const d of list) actionableAt.set(`${doc}:${d.line}`, { ref: d.ref, code: d.code }); - diagnostics.set( - vscode.Uri.file(`${p.root}/${doc}`), - list.map((d) => { - const severity = - d.severity === 'error' - ? vscode.DiagnosticSeverity.Error - : d.severity === 'information' - ? vscode.DiagnosticSeverity.Information - : vscode.DiagnosticSeverity.Warning; - const diag = new vscode.Diagnostic( - new vscode.Range(d.line - 1, 0, d.line - 1, 1000), - d.message, - severity - ); - diag.source = 'docref'; - diag.code = d.code; - return diag; - }) - ); + for (const s of scans) { + const level = s.project.check.level; + for (const [doc, list] of diagnosticsFromReport(s.report, level)) { + const abs = join(s.root, doc); + for (const d of list) actionableAt.set(`${abs}:${d.line}`, { ref: d.ref, code: d.code, root: s.root }); + diagnostics.set( + vscode.Uri.file(abs), + list.map((d) => { + const severity = + d.severity === 'error' + ? vscode.DiagnosticSeverity.Error + : d.severity === 'information' + ? vscode.DiagnosticSeverity.Information + : vscode.DiagnosticSeverity.Warning; + const diag = new vscode.Diagnostic( + new vscode.Range(d.line - 1, 0, d.line - 1, 1000), + d.message, + severity + ); + diag.source = 'docref'; + diag.code = d.code; + return diag; + }) + ); + } } - status.text = statusText(report, level); + status.text = aggregateStatusText(scans.map((s) => ({ report: s.report, level: s.project.check.level }))); tree.refresh(); anchorTree.refresh(); } @@ -397,20 +478,39 @@ export function activate(context: vscode.ExtensionContext): void { // branch switches, or any external edit update the views unprompted const watcher = vscode.workspace.createFileSystemWatcher('**/*'); function onFsEvent(uri: vscode.Uri): void { - const root = workspaceRoot(); - if (!root) return; - const rel = relPath(root, uri.fsPath); - if (rel === null) return; // outside the workspace (POSIX/Windows correct) - if (isRelevantChange(rel, cachedRefPaths, cachedAnchorFileSet)) rescanSoon(); + // any docref config appearing/changing may add or reshape a project + if (/[\\/]docref\.(toml|lock)$/.test(uri.fsPath)) { + rescanSoon(); + return; + } + // otherwise, relevant only if it belongs to a known project's scan set. + // relPath does the containment check AND separator normalization (\\ -> /) + // and returns null when the path is outside root — so it is Windows-correct + // where a hand-rolled `startsWith(root + '/')` guard would wrongly skip + // every event on Windows (fsPath uses backslashes). + for (const s of scans) { + const rel = relPath(s.root, uri.fsPath); + if (rel !== null && rel !== '' && isRelevantChange(rel, s.refPaths, s.anchorFileSet)) { + rescanSoon(); + return; + } + } } async function refFromSelection(): Promise { const editor = vscode.window.activeTextEditor; - const root = workspaceRoot(); - if (!editor || !root || editor.selection.isEmpty) { + if (!editor || editor.selection.isEmpty) { void vscode.window.showWarningMessage('docref: select the code to anchor first.'); return null; } + // the ref is relative to the file's OWN governing project root, so it + // resolves in a multi-repo workspace regardless of the workspace folder + const pr = projectFor(editor.document.uri.fsPath); + if (!pr) { + void vscode.window.showWarningMessage('docref: this file is not inside a docref project (no docref.toml above it).'); + return null; + } + const root = pr.root; const doc = editor.document; const rel = relPath(root, doc.uri.fsPath) ?? ''; const [startLine, endLine] = normalizeSelectionLines( @@ -472,7 +572,8 @@ export function activate(context: vscode.ExtensionContext): void { /** Offer the freshly anchored ref: stage it, or copy paste-ready text. */ async function offerRef(ref: string): Promise { - const resolved = await resolveRefNow(ref); + const pr = activeProject(); + const resolved = await resolveRefNow(ref, pr); const pinned = resolved ? `${ref}:${resolved.sha}` : ref; await vscode.env.clipboard.writeText(pinned); const action = await vscode.window.showInformationMessage( @@ -482,7 +583,7 @@ export function activate(context: vscode.ExtensionContext): void { 'Copy snippet' ); if (action === 'Stage') { - await stageAdd(ref); + await stageAdd(ref, pr); } else if (action === 'Copy claim') { await vscode.env.clipboard.writeText( claimBlockText([{ ref, ...(resolved ? { sha: resolved.sha } : {}) }]) @@ -506,10 +607,11 @@ export function activate(context: vscode.ExtensionContext): void { const lensProvider: vscode.CodeLensProvider = { async provideCodeLenses(doc) { - const root = workspaceRoot(); - if (!root || !index) return []; - const rel = relPath(root, doc.uri.fsPath) ?? ''; - const mine = index.refs.filter((r) => r.ref.split('#')[0] === rel && !r.ref.includes(':')); + const pr = projectFor(doc.uri.fsPath); + const scan = pr ? scanForRoot(pr.root) : undefined; + if (!pr || !scan) return []; + const rel = relPath(pr.root, doc.uri.fsPath) ?? ''; + const mine = scan.index.refs.filter((r) => r.ref.split('#')[0] === rel && !r.ref.includes(':')); if (mine.length === 0) return []; const lineFor = new Map(); @@ -534,7 +636,7 @@ export function activate(context: vscode.ExtensionContext): void { new vscode.CodeLens(new vscode.Range(line - 1, 0, line - 1, 0), { title: `docref: referenced by ${r.locations.length} location(s)`, command: 'docref.showLocations', - arguments: [r.locations] + arguments: [r.locations, pr.root] }) ); } @@ -577,9 +679,11 @@ export function activate(context: vscode.ExtensionContext): void { return [claimScaffold(range, trigLen ? before.slice(before.length - trigLen) : 'docref')]; } if (ctx.alias) return; // cross-repo file/symbol listing is not offered - const p = project(); - const root = workspaceRoot(); - if (!p || !root) return; + // the reference is completed against the markdown file's OWN governing + // project, so a multi-repo workspace offers each doc its own repo's files + const pr = projectFor(doc.uri.fsPath); + if (!pr) return; + const { project: p, root } = pr; const word = ctx.phase === 'path' ? ctx.partial.slice(ctx.partial.lastIndexOf('/') + 1) : ctx.partial; const range = new vscode.Range( @@ -589,15 +693,16 @@ export function activate(context: vscode.ExtensionContext): void { position.character ); if (ctx.phase === 'path') { - // populated by rescan; fetch once if completion fires first - if (anchorFileList.length === 0) { + // prefer the list cached by rescan; fetch once if completion fires first + let files = scanForRoot(root)?.anchorFileList ?? []; + if (files.length === 0) { try { - anchorFileList = await anchorFiles(p); + files = await anchorFiles(p); } catch { /* leave empty */ } } - return pathCompletions(anchorFileList, ctx.partial, range); + return pathCompletions(files, ctx.partial, range); } const items = await fragmentCompletions(root, ctx.path, ctx.kind, range); if (items.length > 0) return items; @@ -624,9 +729,6 @@ export function activate(context: vscode.ExtensionContext): void { const QUICK_FIX = vscode.CodeActionKind.QuickFix; const codeActionProvider: vscode.CodeActionProvider = { provideCodeActions(doc, _range, ctx) { - const root = workspaceRoot(); - if (!root) return; - const rel = relPath(root, doc.uri.fsPath) ?? ''; const actions: vscode.CodeAction[] = []; const make = ( title: string, @@ -643,13 +745,15 @@ export function activate(context: vscode.ExtensionContext): void { }; for (const diag of ctx.diagnostics) { if (diag.source !== 'docref') continue; - const info = actionableAt.get(`${rel}:${diag.range.start.line + 1}`); + // actionableAt is keyed by absolute doc path; a ref-bearing action + // carries the governing project root so it targets the right repo + const info = actionableAt.get(`${doc.uri.fsPath}:${diag.range.start.line + 1}`); const fixes = quickFixesForState(String(diag.code)); if (info?.ref && fixes.openCode) { - make('docref: Open referenced code', 'docref.openAnchor', [info.ref], diag, true); + make('docref: Open referenced code', 'docref.openAnchor', [info.ref, info.root], diag, true); } if (info?.ref && fixes.showDiff) { - make('docref: Show drift diff (approved vs current)', 'docref.showDriftForRef', [info.ref], diag); + make('docref: Show drift diff (approved vs current)', 'docref.showDriftForRef', [info.ref, info.root], diag); } if (fixes.approve) make('docref: Approve claims in this document', 'docref.approveClaims', [], diag); if (fixes.refresh) make('docref: Refresh stale snippets', 'docref.refreshSnippets', [], diag); @@ -675,10 +779,10 @@ export function activate(context: vscode.ExtensionContext): void { vscode.window.registerTreeDataProvider('docrefStaged', stageTree), vscode.commands.registerCommand('docref.stageSelection', async () => { const ref = await refFromSelection(); - if (ref) await stageAdd(ref); + if (ref) await stageAdd(ref, activeProject()); }), vscode.commands.registerCommand('docref.stageRef', async (n: SidebarNode) => { - if (n?.ref) await stageAdd(n.ref); + if (n?.ref) await stageAdd(n.ref, projectForNode(n)); }), vscode.commands.registerCommand('docref.unstage', async (n: SidebarNode) => { staged = staged.filter((s) => s.ref !== n.ref); @@ -695,8 +799,9 @@ export function activate(context: vscode.ExtensionContext): void { insertReference(n, 'snippet') ), vscode.commands.registerCommand('docref.removeEverywhere', async (n: SidebarNode) => { - const p = project(); - if (!p || !n?.ref) return; + const pr = projectForNode(n); + if (!pr || !n?.ref) return; + const p = pr.project; const marker = n.ref.includes('#@') ? ' Its marker pair is deleted from the code.' : ''; const confirmed = await vscode.window.showWarningMessage( `Delete ${n.ref} everywhere? Snippets are removed whole; claim comments are removed and the prose stays.${marker}`, @@ -744,11 +849,12 @@ export function activate(context: vscode.ExtensionContext): void { // `docref repo add`). Invoked from the palette, or from the broken-ref // quick fix prefilled with the undeclared alias. vscode.commands.registerCommand('docref.addRepository', async (prefilledAlias?: string) => { - const p = project(); - if (!p) { - void vscode.window.showWarningMessage('docref: open a workspace with a docref.toml first.'); + const pr = activeProject(); + if (!pr) { + void vscode.window.showWarningMessage('docref: open a file inside a docref project (with a docref.toml) first.'); return; } + const p = pr.project; const alias = await vscode.window.showInputBox({ title: 'docref: add repository', prompt: 'Alias for the cross-repo reference', @@ -785,9 +891,9 @@ export function activate(context: vscode.ExtensionContext): void { }), vscode.commands.registerCommand('docref.rescan', () => rescan()), vscode.commands.registerCommand('docref.refreshSnippets', async () => { - const p = project(); - if (!p) return; - const { changedDocs } = await refresh(p); + const pr = activeProject(); + if (!pr) return; + const { changedDocs } = await refresh(pr.project); void vscode.window.showInformationMessage( changedDocs.length ? `docref: refreshed ${changedDocs.join(', ')}` @@ -798,9 +904,11 @@ export function activate(context: vscode.ExtensionContext): void { vscode.workspace.registerTextDocumentContentProvider('docref-drift', driftProvider), vscode.commands.registerCommand('docref.showDrift', async () => { const editor = vscode.window.activeTextEditor; - const root = workspaceRoot(); - const rel = editor && root ? (relPath(root, editor.document.uri.fsPath) ?? undefined) : undefined; - const { opened, total } = await openDrift(rel?.endsWith('.md') ? { doc: rel } : undefined); + const pr = editor ? projectFor(editor.document.uri.fsPath) : null; + const rel = editor && pr ? (relPath(pr.root, editor.document.uri.fsPath) ?? undefined) : undefined; + const { opened, total } = await openDrift( + pr ? { ...(rel?.endsWith('.md') ? { doc: rel } : {}), root: pr.root } : undefined + ); if (opened === 0) { void vscode.window.showInformationMessage( total === 0 @@ -810,13 +918,12 @@ export function activate(context: vscode.ExtensionContext): void { } }), vscode.commands.registerCommand('docref.approveClaims', async () => { - const p = project(); const editor = vscode.window.activeTextEditor; - const root = workspaceRoot(); - if (!p || !editor || !root) return; - const rel = relPath(root, editor.document.uri.fsPath) ?? ''; + const pr = editor ? projectFor(editor.document.uri.fsPath) : null; + if (!editor || !pr) return; + const rel = relPath(pr.root, editor.document.uri.fsPath) ?? ''; // show the evidence first: one diff tab per recoverable claim - const { opened } = await openDrift({ doc: rel }); + const { opened } = await openDrift({ doc: rel, root: pr.root }); const confirmed = await vscode.window.showWarningMessage( `Approve all claims in ${rel}? Only do this after reading the claimed prose against the current code.` + (opened > 0 ? ` ${opened} drift diff(s) are open in the editor.` : ''), @@ -824,18 +931,21 @@ export function activate(context: vscode.ExtensionContext): void { 'Approve' ); if (confirmed !== 'Approve') return; - const result = await approve(p, [rel]); + const result = await approve(pr.project, [rel]); void vscode.window.showInformationMessage( `docref: approved ${result.approved} claim(s)` + (result.refused.length ? `, refused ${result.refused.length} broken` : '') ); await rescan(); }), - // Accepts a raw ref string (tree click, code action) or a sidebar node - // (right-click menu), so one command serves every entry point. - vscode.commands.registerCommand('docref.openAnchor', async (arg: string | SidebarNode) => { - const root = workspaceRoot(); + // Accepts a raw ref string + its project root (tree click, code action) or a + // sidebar node carrying its own root (right-click menu), so one command + // serves every entry point and resolves against the right repo. Staged + // nodes carry no root, so fall back to the active editor's project (as the + // other staged-item actions do via projectForNode) rather than no-op. + vscode.commands.registerCommand('docref.openAnchor', async (arg: string | SidebarNode, rootArg?: string) => { const refRaw = typeof arg === 'string' ? arg : arg?.ref; + const root = (typeof arg === 'string' ? rootArg : arg?.root) ?? activeProject()?.root; if (!root || !refRaw) return; try { const ref = parseRef(refRaw); @@ -848,7 +958,7 @@ export function activate(context: vscode.ExtensionContext): void { const anchor = await resolveAnchor(workingTreeSource(root), ref); await vscode.commands.executeCommand( 'docref.openLocation', - ref.path, + join(root, ref.path), anchor.span?.startLine ?? 1 ); } catch (e) { @@ -857,36 +967,41 @@ export function activate(context: vscode.ExtensionContext): void { }), // The approved-vs-current diff for ONE claim (from the squiggle quick fix // or a sidebar right-click), as opposed to docref.showDrift's whole-file - // sweep. Accepts a ref string or a sidebar node. - vscode.commands.registerCommand('docref.showDriftForRef', async (arg: string | SidebarNode) => { + // sweep. Accepts a ref string + root or a sidebar node. + vscode.commands.registerCommand('docref.showDriftForRef', async (arg: string | SidebarNode, rootArg?: string) => { const ref = typeof arg === 'string' ? arg : arg?.ref; + const root = typeof arg === 'string' ? rootArg : arg?.root; if (!ref) return; - const { opened } = await openDrift({ ref }); + const { opened } = await openDrift({ ref, ...(root ? { root } : {}) }); if (opened === 0) { void vscode.window.showInformationMessage( `docref: no approved-vs-current diff for ${ref} — it is not a drifted claim with a recoverable approved state.` ); } }), - vscode.commands.registerCommand('docref.openLocation', async (doc: string, line: number) => { - const root = workspaceRoot(); - if (!root) return; - const editor = await vscode.window.showTextDocument(vscode.Uri.file(`${root}/${doc}`)); + // Opens an ABSOLUTE file path; callers join the doc onto its project root. + vscode.commands.registerCommand('docref.openLocation', async (absPath: string, line: number) => { + const editor = await vscode.window.showTextDocument(vscode.Uri.file(absPath)); const pos = new vscode.Position(line - 1, 0); editor.selection = new vscode.Selection(pos, pos); editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter); }), vscode.commands.registerCommand( 'docref.showLocations', - async (locations: { doc: string; line: number; kind: string }[]) => { + async (locations: { doc: string; line: number; kind: string }[], root: string) => { const picked = await vscode.window.showQuickPick( locations.map((l) => ({ label: `${l.doc}:${l.line}`, description: l.kind, l })), { title: 'docref: referencing locations' } ); - if (picked) await vscode.commands.executeCommand('docref.openLocation', picked.l.doc, picked.l.line); + if (picked) await vscode.commands.executeCommand('docref.openLocation', join(root, picked.l.doc), picked.l.line); } ), vscode.workspace.onDidSaveTextDocument(() => rescanSoon()), + // free a drift virtual doc when its diff tab closes, so driftDocs stays + // bounded by open diffs rather than leaking for the session + vscode.workspace.onDidCloseTextDocument((doc) => { + if (doc.uri.scheme === 'docref-drift') driftDocs.delete(doc.uri.path); + }), watcher, watcher.onDidChange(onFsEvent), watcher.onDidCreate(onFsEvent), diff --git a/packages/vscode/src/logic.test.ts b/packages/vscode/src/logic.test.ts index dde0060..b6c6453 100644 --- a/packages/vscode/src/logic.test.ts +++ b/packages/vscode/src/logic.test.ts @@ -16,6 +16,9 @@ import { buildReferencesTree, buildAnchorsTree, buildStageTree, + tagRoot, + mergeProjectTrees, + aggregateStatusText, type SidebarNode, isRelevantChange, relPath, @@ -485,6 +488,61 @@ describe('buildStageTree', () => { }); }); +describe('multi-project aggregation (issue #4)', () => { + // A multi-repo workspace scans each governing docref.toml separately; these + // pure helpers merge the per-project trees and status so one repo's view is + // unchanged and several are shown without collision, each node tagged with + // its root so actions target the right repo. + const nodesFor = (label: string): SidebarNode[] => [ + { type: 'group', id: 'all', label, mood: 'ok', children: [{ type: 'file', id: `f:${label}`, label, path: 'x.ts' }] } + ]; + + it('tagRoot stamps the root through the whole subtree', () => { + const nodes = nodesFor('a'); + tagRoot(nodes, '/repo/a'); + expect(nodes[0]!.root).toBe('/repo/a'); + expect(nodes[0]!.children![0]!.root).toBe('/repo/a'); + }); + + it('a single project passes its tree through unchanged but root-tagged', () => { + const merged = mergeProjectTrees([{ root: '/ws', nodes: nodesFor('all') }]); + expect(merged.map((n) => n.id)).toEqual(['all']); // no wrapper group + expect(merged[0]!.root).toBe('/ws'); + }); + + it('several projects nest under one group each, labeled by root, empties dropped', () => { + const merged = mergeProjectTrees([ + { root: '/ws/sdk', nodes: nodesFor('sdk') }, + { root: '/ws/docs', nodes: nodesFor('docs') }, + { root: '/ws/empty', nodes: [] } + ]); + expect(merged.map((n) => n.label)).toEqual(['sdk', 'docs']); // empty project dropped + expect(merged.map((n) => n.id)).toEqual(['project:/ws/sdk', 'project:/ws/docs']); + // each project's nodes carry that project's root, so actions hit the right repo + expect(merged[0]!.children![0]!.root).toBe('/ws/sdk'); + expect(merged[1]!.children![0]!.root).toBe('/ws/docs'); + }); + + it('aggregateStatusText sums project summaries and keeps the strictest gate', () => { + expect(aggregateStatusText([])).toBe('docref'); + // one project → identical to statusText + expect(aggregateStatusText([{ report: REPORT, level: 'strict' }])).toBe(statusText(REPORT, 'strict')); + // two projects: broken counts merge; a strict project keeps the error glyph + const advisoryClean: Report = { + entries: [], + errors: [], + unusedAnchors: [], + summary: { upToDate: 5, staleSnippet: 0, staleClaim: 0, broken: 0 } + }; + const merged = aggregateStatusText([ + { report: REPORT, level: 'strict' }, + { report: advisoryClean, level: 'advisory' } + ]); + expect(merged).toContain('$(error)'); + expect(merged).toContain('1 broken'); + }); +}); + describe('isRelevantChange', () => { // The background watcher sees every filesystem event; only changes // that can move a docref state are worth a rescan. diff --git a/packages/vscode/src/logic.ts b/packages/vscode/src/logic.ts index e9425d6..afffcca 100644 --- a/packages/vscode/src/logic.ts +++ b/packages/vscode/src/logic.ts @@ -279,6 +279,10 @@ export type SidebarNode = { path?: string; ref?: string; kind?: string; + /** The governing project root for this node, so a node action (open a doc, + * resolve a ref) targets the right repo in a multi-repo workspace. Stamped by + * the thin vscode layer after the (project-relative) tree is built. */ + root?: string; children?: SidebarNode[]; }; @@ -495,6 +499,72 @@ export function isRelevantChange( return refPaths.has(rel) || anchorFiles.has(rel); } +/** Recursively stamp a project root onto a freshly built (project-relative) + * tree, so each node action resolves against the right repo in a multi-repo + * workspace. Mutates and returns the same nodes. */ +export function tagRoot(nodes: SidebarNode[], root: string): SidebarNode[] { + for (const n of nodes) { + n.root = root; + if (n.children) tagRoot(n.children, root); + } + return nodes; +} + +/** + * Aggregate per-project sidebar trees. One project → its tree unchanged (so the + * single-repo view is exactly as before); several → each non-empty tree under a + * collapsed group labeled by the project's root, so a multi-repo workspace shows + * every repo without doc-path collisions. Every node is tagged with its root. + */ +export function mergeProjectTrees(perProject: { root: string; nodes: SidebarNode[] }[]): SidebarNode[] { + if (perProject.length === 1) return tagRoot(perProject[0]!.nodes, perProject[0]!.root); + const out: SidebarNode[] = []; + for (const { root, nodes } of perProject) { + const tagged = tagRoot(nodes, root); + if (tagged.length === 0) continue; + out.push({ + type: 'group', + id: `project:${root}`, + label: root.split(/[\\/]/).pop() || root, + description: root, + mood: 'ok', + root, + children: tagged + }); + } + return out; +} + +/** + * The one-line status across every scanned project. One project → its own status + * text unchanged; several → their summaries merged, with the strictest gate any + * project uses driving the glyph (the loudest, most honest default). + */ +export function aggregateStatusText(projects: { report: Report; level: GateLevel }[]): string { + if (projects.length === 0) return 'docref'; + if (projects.length === 1) return statusText(projects[0]!.report, projects[0]!.level); + const merged: Report = { + entries: projects.flatMap((p) => p.report.entries), + errors: projects.flatMap((p) => p.report.errors), + unusedAnchors: projects.flatMap((p) => p.report.unusedAnchors), + summary: projects.reduce( + (a, p) => ({ + upToDate: a.upToDate + p.report.summary.upToDate, + staleSnippet: a.staleSnippet + p.report.summary.staleSnippet, + staleClaim: a.staleClaim + p.report.summary.staleClaim, + broken: a.broken + p.report.summary.broken + }), + { upToDate: 0, staleSnippet: 0, staleClaim: 0, broken: 0 } + ) + }; + const level: GateLevel = projects.some((p) => p.level === 'strict') + ? 'strict' + : projects.some((p) => p.level === 'lenient') + ? 'lenient' + : 'advisory'; + return statusText(merged, level); +} + export function statusText(report: Report | null, level: GateLevel = 'strict'): string { if (!report) return 'docref'; const s = report.summary; diff --git a/scripts/build-vendored-grammars.mjs b/scripts/build-vendored-grammars.mjs index 9cb5bb4..ea3d0a8 100644 --- a/scripts/build-vendored-grammars.mjs +++ b/scripts/build-vendored-grammars.mjs @@ -18,6 +18,19 @@ import { tmpdir } from 'node:os'; const CLI_VERSION = '0.25.10'; const GRAMMARS = [ + { + // tree-sitter-wasms builds bash against a mismatched ABI: its external + // scanner imports `isalpha`, which no web-tree-sitter runtime exports, so + // parsing `[ -f x ]`, extglob, or $'…' crashes the emscripten lazy stub + // with a raw "resolved is not a function" (issue #1). Built from source + // with the pinned CLI, the import resolves and those constructs parse. + name: 'bash', + repo: 'https://github.com/tree-sitter/tree-sitter-bash.git', + commit: 'a06c2e4415e9bc0346c6b86d401879ffb44058f7', // v0.25.1 + fileTypes: ['sh', 'bash'], + license: 'MIT', + sha256: '474a86db0ded2b2478e130640ebfa9493143862f57546c19cf8a8d9bf26e3a17' + }, { // proto3/proto2; every declaration node (message/enum/service/rpc) exposes // a `name` field, so core's generic collector handles it unchanged.