diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 304ee6f7..340e3f93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,9 @@ jobs: run: bun install --frozen-lockfile --ignore-scripts - name: Build workspace - run: bun run build + run: | + rustup toolchain install 1.89.0 --profile minimal --target wasm32-unknown-unknown + bun run build - name: Verify generated plugin projection run: >- @@ -63,6 +65,7 @@ jobs: - name: Verify Node consumers run: | + node --test packages/context-cli/scripts/evidence-wasm.test.mjs bun run test:dist node packages/context-cli/dist/cli.js --version node packages/context-cli/dist/cli.js --help diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a4f7681b..fc4a1e18 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -128,7 +128,9 @@ jobs: >/dev/null - name: Build workspace - run: bun run build + run: | + rustup toolchain install 1.89.0 --profile minimal --target wasm32-unknown-unknown + bun run build - name: Prepare publish artifacts run: bun run release:prepare diff --git a/.github/workflows/verify-full.yml b/.github/workflows/verify-full.yml index dd23f8fe..fdced91b 100644 --- a/.github/workflows/verify-full.yml +++ b/.github/workflows/verify-full.yml @@ -47,7 +47,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile --ignore-scripts - name: Build workspace - run: bun run build + run: | + rustup toolchain install 1.89.0 --profile minimal --target wasm32-unknown-unknown + bun run build - name: Verification env: VERIFY_SCRIPT: ${{ inputs.full && 'verify:full' || 'verify' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4faedfc8..07e84432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to Context are documented here. +## 0.7.42 - 2026-09-30 + +- Read approved knowledge and recorded code evidence remotely without requiring a checkout; preserve local and offline retrieval paths. +- Bundle a read-only repository Wasm plugin that attaches section evidence and commit-specific source URLs to knowledge reads. +- Add `context evidence install` and workspace initialization support with explicit nested-repository scope selection and protection for custom plugins. +- Reuse ready evidence links without repeated source-registry lookup or link formatting, while retaining source verification and partial-result boundaries. + ## 0.7.40 - 2026-09-29 - Use optional read-only remote code evidence at recorded repository baselines before retrieving unavailable local sources. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index dba86feb..c74023c3 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -19,6 +19,9 @@ and the same bundled plugin tree that is shipped to users. ## Prerequisites - Bun for installing workspace dependencies, building, and running tests. +- Rust 1.89.0 with `wasm32-unknown-unknown` for building the evidence plugin + (`rustup toolchain install 1.89.0 --profile minimal --target wasm32-unknown-unknown`). + Published CLI users do not need Rust. See [the ABI](packages/context-evidence-wasm/README.md). - Node.js and npm for the globally linked or published CLI surface. - Claude Code or Codex only when testing the corresponding agent plugin. diff --git a/bun.lock b/bun.lock index b6606e71..b54ecf17 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ }, "packages/context": { "name": "@c4a/context", - "version": "0.7.38", + "version": "0.7.42", "dependencies": { "yaml": "^2.5.1", "zod": "^3.23.8", @@ -26,7 +26,7 @@ }, "packages/context-cli": { "name": "@c4a/context-cli", - "version": "0.7.38", + "version": "0.7.42", "bin": { "context": "dist/cli.js", }, @@ -73,7 +73,7 @@ }, "packages/core": { "name": "@c4a/core", - "version": "0.7.38", + "version": "0.7.42", "dependencies": { "picomatch": "^4.0.4", "yaml": "^2.4.5", @@ -85,7 +85,7 @@ }, "packages/dev-cli": { "name": "@c4a/dev-cli", - "version": "0.7.38", + "version": "0.7.42", "dependencies": { "@c4a/context": "workspace:*", "@c4a/core": "workspace:*", @@ -97,7 +97,7 @@ }, "packages/extract": { "name": "@c4a/extract", - "version": "0.7.38", + "version": "0.7.42", "bin": { "c4a-extract-code": "./dist/bin/c4a-extract-code.js", }, @@ -112,7 +112,7 @@ }, "packages/extract-contract": { "name": "@c4a/extract-contract", - "version": "0.7.38", + "version": "0.7.42", "dependencies": { "@c4a/core": "workspace:*", "graphql": "^16.14.2", @@ -122,7 +122,7 @@ }, "packages/extract-go": { "name": "@c4a/extract-go", - "version": "0.7.38", + "version": "0.7.42", "dependencies": { "@c4a/core": "workspace:*", "@c4a/extract": "workspace:*", @@ -132,7 +132,7 @@ }, "packages/extract-mdx": { "name": "@c4a/extract-mdx", - "version": "0.7.38", + "version": "0.7.42", "dependencies": { "@c4a/core": "workspace:*", "remark-mdx": "^3.1.1", @@ -144,7 +144,7 @@ }, "packages/extract-proto": { "name": "@c4a/extract-proto", - "version": "0.7.38", + "version": "0.7.42", "dependencies": { "@c4a/core": "workspace:*", "zod": "^3.23.8", @@ -152,7 +152,7 @@ }, "packages/extract-rush": { "name": "@c4a/extract-rush", - "version": "0.7.38", + "version": "0.7.42", "dependencies": { "@c4a/core": "workspace:*", "typescript": "^5.5.4", @@ -162,7 +162,7 @@ }, "packages/extract-sql": { "name": "@c4a/extract-sql", - "version": "0.7.38", + "version": "0.7.42", "dependencies": { "@c4a/core": "workspace:*", "node-sql-parser": "^5.4.0", @@ -171,7 +171,7 @@ }, "packages/extract-style": { "name": "@c4a/extract-style", - "version": "0.7.38", + "version": "0.7.42", "dependencies": { "@c4a/core": "workspace:*", "postcss": "^8.5.26", @@ -183,7 +183,7 @@ }, "packages/extract-thrift": { "name": "@c4a/extract-thrift", - "version": "0.7.38", + "version": "0.7.42", "dependencies": { "@c4a/core": "workspace:*", "zod": "^3.23.8", @@ -191,7 +191,7 @@ }, "packages/extract-ts": { "name": "@c4a/extract-ts", - "version": "0.7.38", + "version": "0.7.42", "dependencies": { "@c4a/core": "workspace:*", "@c4a/extract": "workspace:*", @@ -202,7 +202,7 @@ }, "packages/tui": { "name": "@c4a/tui", - "version": "0.7.38", + "version": "0.7.42", "dependencies": { "ink": "^5.0.0", "react": "^18.3.1", diff --git a/package.json b/package.json index 8eedeb2a..f768d4af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "context", - "version": "0.7.40", + "version": "0.7.42", "packageManager": "bun@1.3.9", "repository": { "type": "git", diff --git a/packages/context-cli/README.md b/packages/context-cli/README.md index e2f1f09f..95949273 100644 --- a/packages/context-cli/README.md +++ b/packages/context-cli/README.md @@ -1,5 +1,22 @@ # Context Agent Runtime +## Repository evidence plugin + +New workspace initialization includes `context-evidence.sourcegraph.wasm` when +the workspace is the repository root (or has no enclosing Git repository). +For existing workspaces run `context evidence install [project-dir] --format json`. +Nested workspaces require explicit `--repository-root ` for full-repo +hosting, or `--plugin-root ` for scoped hosting. Use the +workspace directory relative to that plugin root as `workspace_root`. +Unknown or modified same-name files are preserved. The installer never commits +or pushes; the artifact must be committed and synchronized before remote use. + +On a compatible read-only MCP host, read/read_many can request +`plugins: [{"name":"context-evidence"}]` to attach registered section sources. +This does not verify original source contents or change local retrieval and +production. Missing enhancement falls back to ordinary metadata reads. +See the [plugin ABI and build instructions](../context-evidence-wasm/README.md). + [简体中文](./README.zh-CN.md) `@c4a/context-cli` ships the local runtime and Agent integration for the diff --git a/packages/context-cli/README.zh-CN.md b/packages/context-cli/README.zh-CN.md index d6c11517..90a79dd3 100644 --- a/packages/context-cli/README.zh-CN.md +++ b/packages/context-cli/README.zh-CN.md @@ -1,5 +1,19 @@ # Context Agent 运行时 +## 仓库证据插件 + +新建工作区位于 Git 根目录(或尚无上级 Git 仓库)时,初始化会附带 +`context-evidence.sourcegraph.wasm`。存量工作区运行 +`context evidence install [project-dir] --format json`;嵌套工作区需显式指定 +`--repository-root `(全仓托管)或 `--plugin-root <登记内容根目录>` +(范围托管),远程读取的 `workspace_root` 是工作区相对插件目录的位置。 +未知或用户修改的同名文件会保留,不自动提交或推送。 + +制品正常提交并同步后,兼容的只读 MCP 宿主可在 read/read_many 中传 +`plugins: [{"name":"context-evidence"}]`,随正文返回登记的章节来源。 +这不表示已经复核原始来源,也不改变本地查询或生产流程;增强不可用时保留原读取路径。 +使用者无需安装 Rust。参见[插件 ABI 与构建说明](../context-evidence-wasm/README.md)。 + [English](./README.md) `@c4a/context-cli` 提供 Context 知识生产工作流的本地运行时和 Agent 接入。虽然 diff --git a/packages/context-cli/context-workflow/provider.yaml b/packages/context-cli/context-workflow/provider.yaml index 035bce00..6632488b 100644 --- a/packages/context-cli/context-workflow/provider.yaml +++ b/packages/context-cli/context-workflow/provider.yaml @@ -1,6 +1,6 @@ schema: agent-graph.provider.v1 id: c4a/context -version: 0.7.40 +version: 0.7.42 name: Context workflow description: Internal work contract for Context knowledge workspaces. graphs: diff --git a/packages/context-cli/package.json b/packages/context-cli/package.json index 66d3c5aa..59256d08 100644 --- a/packages/context-cli/package.json +++ b/packages/context-cli/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/context-cli", "description": "Local runtime and Agent integration for traceable knowledge production", - "version": "0.7.40", + "version": "0.7.42", "type": "module", "license": "MIT", "engines": { @@ -35,14 +35,16 @@ "README.zh-CN.md" ], "scripts": { - "build": "rm -rf dist && bun run ../build.ts src/cli.ts src/parserEntryWorker.ts --shebang=node && bun run scripts/build-workflow.ts && bun run scripts/build-plugin.ts && bun run scripts/build-indexers.ts && bun run scripts/build-diagrams.ts && cp ../../LICENSE dist/LICENSE", + "build": "rm -rf dist && bun run ../build.ts src/cli.ts src/parserEntryWorker.ts --shebang=node && bun run scripts/build-evidence.ts && bun run scripts/build-workflow.ts && bun run scripts/build-plugin.ts && bun run scripts/build-indexers.ts && bun run scripts/build-diagrams.ts && cp ../../LICENSE dist/LICENSE", + "build:evidence": "bun run scripts/build-evidence.ts", + "test:evidence": "bun run build:evidence && node --test scripts/evidence-wasm.test.mjs", "build:indexers": "bun run scripts/build-indexers.ts", "generate:article-templates": "bun run scripts/generate-article-templates.ts", "build:workflow": "bun run scripts/build-workflow.ts", "build:plugin": "bun run scripts/build-plugin.ts", "postinstall": "node scripts/postinstall.mjs", "typecheck": "tsc --noEmit", - "test": "node --test scripts/test-shards.test.mjs && CONTEXT_RUNTIME_EVENTS_DISABLED=1 bun run scripts/run-unit-tests.mjs --max-concurrency=1 --timeout=120000", + "test": "node --test scripts/test-shards.test.mjs scripts/evidence-wasm.test.mjs && CONTEXT_RUNTIME_EVENTS_DISABLED=1 bun run scripts/run-unit-tests.mjs --max-concurrency=1 --timeout=120000", "test:full": "CONTEXT_RUNTIME_EVENTS_DISABLED=1 bun run scripts/run-unit-tests.mjs --full --max-concurrency=1 --timeout=1200000", "test:full-only": "CONTEXT_RUNTIME_EVENTS_DISABLED=1 bun run scripts/run-unit-tests.mjs --full-only --max-concurrency=1 --timeout=1200000", "lint": "eslint src --cache --cache-location .tmp/eslint-cache", diff --git a/packages/context-cli/scripts/build-evidence.ts b/packages/context-cli/scripts/build-evidence.ts new file mode 100644 index 00000000..bf6beb37 --- /dev/null +++ b/packages/context-cli/scripts/build-evidence.ts @@ -0,0 +1,54 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +const root = resolve(import.meta.dir, ".."); +const crate = resolve(root, "../context-evidence-wasm"); +const cargo = JSON.parse(execFileSync("cargo", ["metadata", "--locked", "--format-version", "1"], { cwd: crate, encoding: "utf8" })); +const remaps = cargo.packages.map((pkg: { manifest_path: string; name: string; version: string }) => + `--remap-path-prefix=${dirname(pkg.manifest_path)}=/sources/${pkg.name}-${pkg.version}`); +execFileSync("cargo", ["build", "--locked", "--release", "--target", "wasm32-unknown-unknown"], { + cwd: crate, stdio: "inherit", env: { ...process.env, CARGO_ENCODED_RUSTFLAGS: remaps.join("\x1f") }, +}); +const bytes = await readFile(resolve(crate, "target/wasm32-unknown-unknown/release/context_evidence_wasm.wasm")); +const module = new WebAssembly.Module(bytes); +const metadata = WebAssembly.Module.customSections(module, "sourcegraph.plugin.v1"); +if (metadata.length !== 1 || JSON.parse(new TextDecoder().decode(metadata[0])).name !== "context-evidence") { + throw new Error("Missing evidence plugin metadata"); +} +const imports = WebAssembly.Module.imports(module); +if (imports.some(item => item.module !== "sourcegraph" || !["read_file", "last_error"].includes(item.name))) { + throw new Error("Unexpected evidence plugin imports"); +} +const output = resolve(root, "dist/evidence"); +await mkdir(output, { recursive: true }); +const license = await readFile(resolve(root, "../../LICENSE"), "utf8"); +await writeFile(resolve(output, "LICENSE"), license); +// Retain dependency licenses in the distributed tool, not runtime registry paths. +const notices: string[] = [license]; +for (const pkg of cargo.packages) { + if (pkg.name === "context-evidence-wasm") continue; + notices.push(`${pkg.name} ${pkg.version} — ${pkg.license ?? "See license below"}`); + for (const file of (await readdir(dirname(pkg.manifest_path))).sort()) { + if (/^(LICENSE|COPYING|NOTICE)([.-]|$)/iu.test(file)) { + try { notices.push(await readFile(resolve(dirname(pkg.manifest_path), file), "utf8")); } catch { /* license directory */ } + } + } +} +await writeFile(resolve(output, "THIRD-PARTY-NOTICES.txt"), notices.join("\n\n")); +// A knowledge repository redistributes a single Wasm: embed the notices so they +// travel with the binary, not only with the CLI used to install it. +function leb(value: number): Buffer { + const result: number[] = []; + do { const byte = value & 127; value >>>= 7; result.push(byte | (value ? 128 : 0)); } while (value); + return Buffer.from(result); +} +const sectionName = Buffer.from("context.licenses"); +const section = Buffer.concat([leb(sectionName.length), sectionName, Buffer.from(notices.join("\n\n"))]); +const artifact = Buffer.concat([bytes, Buffer.from([0]), leb(section.length), section]); +if (!WebAssembly.validate(artifact)) throw new Error("Invalid evidence Wasm artifact"); +await writeFile(resolve(output, "context-evidence.sourcegraph.wasm"), artifact); +const previous = JSON.parse(await readFile(resolve(crate, "official-digests.json"), "utf8")) as string[]; +await writeFile(resolve(output, "manifest.json"), `${JSON.stringify({ sha256: createHash("sha256").update(artifact).digest("hex"), previous }, null, 2)}\n`); +console.log(`Evidence Wasm: ${artifact.length} bytes`); diff --git a/packages/context-cli/scripts/evidence-artifact-smoke.mjs b/packages/context-cli/scripts/evidence-artifact-smoke.mjs new file mode 100644 index 00000000..c1eeda2e --- /dev/null +++ b/packages/context-cli/scripts/evidence-artifact-smoke.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Verifies the actual package projection with Node, not a source-mode installer. +const archive = process.argv[2]; +if (!archive) throw new Error('Pass the CLI tarball path'); +const root = resolve(fileURLToPath(new URL('..', import.meta.url)), '.tmp/evidence-artifact'); +mkdirSync(root, { recursive: true }); +const work = mkdtempSync(join(root, 'case-')); +execFileSync('tar', ['-xzf', resolve(archive), '-C', work]); +const packageRoot = join(work, 'package'); +const runtimeRoot = existsSync(join(packageRoot, 'cli.js')) ? packageRoot : join(packageRoot, 'dist'); +const cli = join(runtimeRoot, 'cli.js'); +const artifact = join(runtimeRoot, 'evidence/context-evidence.sourcegraph.wasm'); +const bytes = readFileSync(artifact); +const project = join(work, 'knowledge-repo'); +mkdirSync(join(project, '.git'), { recursive: true }); +const env = { ...process.env, PATH: join(work, 'no-executables'), CONTEXT_RUNTIME_EVENTS_DISABLED: '1' }; +const run = () => JSON.parse(execFileSync(process.execPath, [cli, 'evidence', 'install', project, '--format', 'json'], { encoding: 'utf8', env })); +assert.equal(run().status, 'installed'); +assert.deepEqual(readFileSync(join(project, 'context-evidence.sourcegraph.wasm')), bytes); +assert.equal(run().status, 'unchanged'); +assert.ok(readFileSync(join(runtimeRoot, 'evidence/THIRD-PARTY-NOTICES.txt'), 'utf8').includes('serde')); +console.log(JSON.stringify({ runtime: process.version, wasmBytes: bytes.length, artifactInstall: 'passed', rustAndGitOnPath: false })); diff --git a/packages/context-cli/scripts/evidence-wasm.test.mjs b/packages/context-cli/scripts/evidence-wasm.test.mjs new file mode 100644 index 00000000..a69e8b40 --- /dev/null +++ b/packages/context-cli/scripts/evidence-wasm.test.mjs @@ -0,0 +1,221 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const bytes = readFileSync(new URL('../dist/evidence/context-evidence.sourcegraph.wasm', import.meta.url)); +const module = new WebAssembly.Module(bytes); +const encoder = new TextEncoder(); +const decoder = new TextDecoder('utf-8', { fatal: true }); +const article = '\nFirst condition.\n\n\n\nSecond condition.\n'; +const source = 'repo:20260901/example'; +const reference = (sourceRef = source) => ({ source_ref: sourceRef, locator: { path: 'src/example.ts', start_line: 3, end_line: 9 }, content_digest: `sha256:${'a'.repeat(64)}` }); +const structure = (refs = [reference()]) => ({ schema_version: 'context.approved-structure.v1', articles: [{ article_id: 'example', path: 'example.md', collection: 'architecture', visibility: 'public', sections: [{ id: 'first', references: refs }, { id: 'second', references: [] }] }] }); +const repo = { sources: [{ name: '20260901', modules: [{ name: 'example', subpath: 'module', git: { remote: 'https://example.org/team/source.git', ref: 'b'.repeat(40) } }] }] }; +function fixtures(refs) { + return { 'knowledge/example.md': article, 'knowledge/structure.yaml': JSON.stringify(structure(refs)), 'sources/repo/index.yaml': JSON.stringify(repo) }; +} +function input(start = 2, end = 2, args = {}) { + return { operation: 'read', args, files: [{ path: 'knowledge/example.md', start_line: start, end_line: end, content: article.split('\n').slice(start - 1, end).join('\n'), truncated: false }] }; +} +function host(files) { + let instance; + let error = ''; + const calls = []; + const put = (data) => { + const buffer = typeof data === 'string' ? encoder.encode(data) : data; + const pointer = instance.exports.alloc(buffer.length) >>> 0; + new Uint8Array(instance.exports.memory.buffer, pointer, buffer.length).set(buffer); + return (BigInt(pointer) << 32n) | BigInt(buffer.length); + }; + instance = new WebAssembly.Instance(module, { sourcegraph: { + read_file(pointer, length) { + const path = decoder.decode(new Uint8Array(instance.exports.memory.buffer, pointer >>> 0, length >>> 0)); + calls.push(path); + if (!(path in files)) { error = 'not found (private host detail)'; return 0n; } + return put(files[path]); + }, + last_error() { return put(error); }, + } }); + return { + calls, + run(value) { + const encoded = JSON.stringify(value); + const request = put(encoded); + const pointer = Number(request >> 32n), length = Number(request & 0xffffffffn); + const packed = BigInt.asUintN(64, instance.exports.enrich(pointer, length)); + const resultPointer = Number(packed >> 32n), resultLength = Number(packed & 0xffffffffn); + const result = JSON.parse(decoder.decode(new Uint8Array(instance.exports.memory.buffer, resultPointer, resultLength))); + instance.exports.dealloc(resultPointer, resultLength); + instance.exports.dealloc(pointer, length); + return result; + }, + }; +} + +test('real artifact metadata and imports', () => { + const sections = WebAssembly.Module.customSections(module, 'sourcegraph.plugin.v1'); + assert.equal(sections.length, 1); + const metadata = JSON.parse(decoder.decode(sections[0])); + assert.equal(metadata.abi_version, 1); + assert.equal(metadata.name, 'context-evidence'); + assert.equal(metadata.default_enabled, true); + assert.deepEqual(metadata.operations, ['read', 'read_many']); + assert.ok(decoder.decode(WebAssembly.Module.customSections(module, 'context.licenses')[0]).includes('serde')); + assert.deepEqual(WebAssembly.Module.imports(module).map(i => `${i.module}.${i.name}`).sort(), ['sourcegraph.last_error', 'sourcegraph.read_file']); +}); +test('shared section fixtures agree with the Context marker contract', () => { + const cases = JSON.parse(readFileSync(new URL('../../context-evidence-wasm/fixtures/sections.json', import.meta.url), 'utf8')); + for (const item of cases) { + const files = fixtures(); files['knowledge/example.md'] = item.text; + const map = structure(); map.articles[0].sections = (item.ids ?? []).map(id => ({ id, references: [] })); + files['knowledge/structure.yaml'] = JSON.stringify(map); + const result = host(files).run(input(1, item.text.split('\n').length)); + if (item.error) assert.ok(result.issues); else assert.deepEqual(result.sections.map(s => s.section_id), item.ids); + } +}); +test('joins exact returned section with registered source, not knowledge revision', () => { + const h = host(fixtures()); + const result = h.run(input()); + assert.deepEqual(result, { sections: [{ path: 'knowledge/example.md', section_id: 'first', references: [{ url: `https://example.org/team/source/blob/${'b'.repeat(40)}/module/src/example.ts#L3-L9` }] }] }); + assert.equal(h.calls.length, 3); + assert.deepEqual(h.run(input()), result); + assert.equal(h.calls.length, 3, 'warm instance does not reread immutable metadata'); + assert.equal(h.run(input(6, 6)).sections[0].section_id, 'second'); +}); +test('optional digest and monorepo root', () => { + const files = Object.fromEntries(Object.entries(fixtures()).map(([p, v]) => [`docs/${p}`, v])); + const request = input(2, 2, { workspace_root: 'docs', include_digest: true }); + request.files[0].path = 'docs/knowledge/example.md'; + assert.equal(host(files).run(request).sections[0].references[0].content_digest, reference().content_digest); +}); +test('source URLs encode file segments and normalize credential-free Git transports', () => { + for (const remote of ['https://github.com/team/source.git', 'git@github.com:team/source.git', 'ssh://git@github.com/team/source.git']) { + const ref = reference(); ref.locator = { path: 'src/a #中文%.ts', start_line: 3, end_line: 3 }; + const files = fixtures([ref]); const registry = structuredClone(repo); + registry.sources[0].modules[0].git.remote = remote; + files['sources/repo/index.yaml'] = JSON.stringify(registry); + assert.deepEqual(host(files).run(input()).sections[0].references, [{url:`https://github.com/team/source/blob/${'b'.repeat(40)}/module/src/a%20%23%E4%B8%AD%E6%96%87%25.ts#L3`}]); + } +}); +test('unsupported routes and nonimmutable revisions keep lossless structured evidence', () => { + for (const [remote, revision] of [['https://bitbucket.org/team/source','b'.repeat(40)], ['ssh://git@example.org:2222/team/source','b'.repeat(40)], ['https://example.org/team/source','main'], ['https://example.org/team/source','abc1234']]) { + const files=fixtures(); const registry=structuredClone(repo); + registry.sources[0].modules[0].git={remote,ref:revision};files['sources/repo/index.yaml']=JSON.stringify(registry); + const result=host(files).run(input()).sections[0].references[0]; + assert.equal(result.url,undefined);assert.equal(result.remote,remote);assert.equal(result.ref,revision);assert.equal(result.path,'module/src/example.ts');assert.equal(result.source_ref,source); + } +}); +test('batch reads do not repeat metadata or leak previous output', () => { + const h = host(fixtures()); + const request = input(); + request.operation = 'read_many'; + request.files.push(input(6, 6).files[0]); + assert.equal(h.run(request).sections.length, 2); + assert.equal(h.calls.length, 3); + assert.deepEqual(h.run({ operation: 'read_many', files: [] }), { sections: [] }); +}); +test('partial references preserve siblings and identify missing registration', () => { + const result = host(fixtures([reference(), reference('repo:20260901/missing')])).run(input()); + assert.equal(result.sections[0].references.length, 1); + assert.equal(result.issues[0].code, 'SOURCE_UNRESOLVED'); + assert.equal(result.issues[0].source_ref, 'repo:20260901/missing'); + assert.equal('status' in result, false); +}); +test('missing and invalid metadata are not zero matches; host errors are sanitized', () => { + for (const bad of [undefined, 'articles: [', JSON.stringify({ schema_version: 'unknown', articles: [] })]) { + const files = fixtures(); + if (bad === undefined) delete files['knowledge/structure.yaml']; else files['knowledge/structure.yaml'] = bad; + const result = host(files).run(input()); + assert.equal(result.issues[0].code, 'EVIDENCE_UNAVAILABLE'); + assert.equal(JSON.stringify(result).includes('private host'), false); + } +}); +test('ambiguous sources and unsafe paths do not create evidence', () => { + for (const change of [r => r.sources[0].modules.push(r.sources[0].modules[0]), r => r.sources[0].modules[0].subpath = '../escape', r => r.sources[0].modules[0].git.remote = 'https://user:secret@example.org/repo']) { + const files = fixtures(); + const changed = structuredClone(repo); change(changed); + files['sources/repo/index.yaml'] = JSON.stringify(changed); + const result = host(files).run(input()); + assert.equal(result.sections[0].references.length, 0); + assert.equal(result.issues[0].code, 'SOURCE_UNRESOLVED'); + assert.equal(JSON.stringify(result).includes('secret'), false); + } + const h = host(fixtures()); + assert.ok(h.run(input(2, 2, { workspace_root: '../escape' })).issues); + assert.equal(h.calls.length, 0); +}); +test('CRLF, fences and entity-encoded IDs follow section semantics', () => { + const content = '```md\r\n\r\n```\r\n\r\nbody\r\n~~~\r\n\r\n~~~\r\n'; + const files = fixtures(); files['knowledge/example.md'] = content; + assert.equal(host(files).run(input(5, 5)).sections[0].section_id, 'first'); + assert.deepEqual(host(files).run(input(2, 2)), { sections: [] }); + files['knowledge/example.md'] = '\nbody\n'; + const map = structure(); map.articles[0].sections[0].id = 'a&b'; + files['knowledge/structure.yaml'] = JSON.stringify(map); + assert.equal(host(files).run(input()).sections[0].section_id, 'a&b'); +}); +test('malformed and duplicate article markers are diagnosed', () => { + for (const content of ['', '', `${article}\n${article}`, '']) { + const files = fixtures(); files['knowledge/example.md'] = content; + assert.ok(host(files).run(input()).issues); + } +}); +test('file, document, note and session sources retain their distinct locations', () => { + const refs = ['file:20260901/input', 'lark:20260901/doc', 'note:20260901/note.md', 'sessions:20260901/session.md'].map(reference); + const files = fixtures(refs); + files['sources/file/index.yaml'] = JSON.stringify({ sources: [{ name: '20260901', modules: [{ name: 'input' }] }] }); + files['sources/lark/index.yaml'] = JSON.stringify({ sources: [{ name: '20260901', modules: [{ name: 'doc', url: 'https://example.org/doc/1' }] }] }); + files['sources/note/20260901/note.md'] = 'saved note'; + files['sources/sessions/20260901/session.md'] = 'saved session'; + const result = host(files).run(input()); + assert.equal(result.issues, undefined); + assert.equal(result.sections[0].references[0].manifest, 'sources/file/20260901/manifest.json'); + assert.equal(result.sections[0].references[1].url, 'https://example.org/doc/1'); + assert.equal(result.sections[0].references[2].materialized_at, 'sources/note/20260901/note.md'); +}); +test('legacy unmarked articles and non-knowledge files do not invent sections', () => { + const files = fixtures(); files['knowledge/example.md'] = '# Legacy\nPlain text'; + assert.deepEqual(host(files).run(input()), { sections: [] }); + const request = input(); request.files[0].path = 'src/file.ts'; + const h = host(files); assert.deepEqual(h.run(request), { sections: [] }); assert.equal(h.calls.length, 0); +}); +test('invalid args and unsupported operations are contained', () => { + const defaultArgs = input(); defaultArgs.args = null; + assert.equal(host(fixtures()).run(defaultArgs).sections[0].section_id, 'first'); + assert.ok(host(fixtures()).run(input(2, 2, { extra: true })).issues); + const request = input(); request.operation = 'execute'; + assert.ok(host(fixtures()).run(request).issues); +}); + +test('duplicate keys, recursive aliases and excessive YAML depth fail without false evidence', () => { + for (const yaml of [ + 'schema_version: context.approved-structure.v1\narticles: []\narticles: []', + 'schema_version: context.approved-structure.v1\narticles: &loop [*loop]', + `schema_version: context.approved-structure.v1\narticles: ${'['.repeat(200)}0${']'.repeat(200)}`, + ]) { + const files = fixtures(); files['knowledge/structure.yaml'] = yaml; + const result = host(files).run(input()); + assert.ok(result.issues); + assert.deepEqual(result.sections, []); + } +}); +test('oversized metadata and output preserve valid diagnostic JSON', () => { + const files = fixtures(); files['knowledge/structure.yaml'] = ' '.repeat(8 * 1024 * 1024 + 1); + assert.ok(host(files).run(input()).issues); + const refs = Array.from({ length: 1300 }, (_, i) => ({ ...reference(), locator: { path: `src/file-${i}.ts`, start_line: 1, end_line: 1 } })); + assert.equal(host(fixtures(refs)).run(input()).issues[0].code, 'OUTPUT_LIMIT'); +}); +test('representative metadata volume is cached without returning the catalog', () => { + const files = fixtures(); const map = structure(); + for (let i = 0; i < 220; i++) { + map.articles.push({ article_id: `other-${i}`, path: `other-${i}.md`, collection: 'architecture', visibility: 'public', + sections: Array.from({ length: 7 }, (_, j) => ({ id: `s-${j}`, references: [reference(), reference(), reference()] })) }); + } + files['knowledge/structure.yaml'] = JSON.stringify(map); + const h = host(files); const result = h.run(input()); + assert.equal(result.sections.length, 1); + assert.ok(JSON.stringify(result).length < 1000); + const reads = h.calls.length; + for (let i = 0; i < 25; i++) assert.deepEqual(h.run(input()), result); + assert.equal(h.calls.length, reads); +}); diff --git a/packages/context-cli/src/__tests__/evidencePlugin.test.ts b/packages/context-cli/src/__tests__/evidencePlugin.test.ts new file mode 100644 index 00000000..c8d74b52 --- /dev/null +++ b/packages/context-cli/src/__tests__/evidencePlugin.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { join, resolve } from "node:path"; +import { installEvidencePlugin, EVIDENCE_PLUGIN } from "../project/evidencePlugin.js"; +import { initContextProject } from "../project/workspace.js"; +import { approvedContextSectionsInMarkdown } from "../project/verifyContextSections.js"; + +const roots: string[] = []; +const artifact = resolve(import.meta.dir, "../../dist/evidence"); +async function fixture() { + const parent = resolve(import.meta.dir, "../../.tmp/evidence-tests"); + await mkdir(parent, { recursive: true }); + const root = await mkdtemp(join(parent, "case-")); roots.push(root); + await mkdir(join(root, ".git")); + return root; +} +afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); + +describe("repository evidence installation", () => { + test("shared Wasm fixtures retain the production section parser semantics", async () => { + const cases = JSON.parse(await readFile(resolve(import.meta.dir, "../../../context-evidence-wasm/fixtures/sections.json"), "utf8")) as Array<{ text: string; ids?: string[]; error?: boolean }>; + for (const item of cases) { + if (item.error) expect(() => approvedContextSectionsInMarkdown(item.text)).toThrow(); + else expect(approvedContextSectionsInMarkdown(item.text).map(section => section.id)).toEqual(item.ids!); + } + }); + test("new repository initialization includes the bundled plugin without running configuration", async () => { + const root = await fixture(); + const result = await initContextProject({ cwd: root, projectDir: ".", dev: true }); + expect(result.evidencePlugin?.status).toBe("installed"); + expect(result.created).toContain(join(root, EVIDENCE_PLUGIN)); + expect(await readFile(join(root, EVIDENCE_PLUGIN))).toEqual(await readFile(join(artifact, EVIDENCE_PLUGIN))); + }); + test("installs actual artifact, repeats without writes, preserves custom content", async () => { + const root = await fixture(); + const args = { projectRoot: root, artifactRoot: artifact }; + expect((await installEvidencePlugin(args)).status).toBe("installed"); + expect(await readFile(join(root, EVIDENCE_PLUGIN))).toEqual(await readFile(join(artifact, EVIDENCE_PLUGIN))); + expect((await installEvidencePlugin(args)).status).toBe("unchanged"); + await writeFile(join(root, EVIDENCE_PLUGIN), "custom"); + expect((await installEvidencePlugin(args)).status).toBe("conflict"); + expect(await readFile(join(root, EVIDENCE_PLUGIN), "utf8")).toBe("custom"); + }); + test("nested workspace requires explicit actual repository root", async () => { + const root = await fixture(); const project = join(root, "docs"); await mkdir(project); + expect((await installEvidencePlugin({ projectRoot: project, artifactRoot: artifact })).status).toBe("needs-repository-root"); + expect((await installEvidencePlugin({ projectRoot: project, repositoryRoot: root, artifactRoot: artifact })).status).toBe("installed"); + await expect(installEvidencePlugin({ projectRoot: project, repositoryRoot: project, artifactRoot: artifact })).rejects.toThrow("actual enclosing Git root"); + }); + test("preserves symlinks", async () => { + const root = await fixture(); const other = join(root, "other"); await writeFile(other, "keep"); + await symlink(other, join(root, EVIDENCE_PLUGIN)); + expect((await installEvidencePlugin({ projectRoot: root, artifactRoot: artifact })).status).toBe("conflict"); + expect(await readFile(other, "utf8")).toBe("keep"); + }); + test("registered content roots are explicit and cannot escape the repository", async () => { + const root = await fixture(); const content = join(root, "docs"); await mkdir(content); + expect((await installEvidencePlugin({ projectRoot: content, pluginRoot: content, artifactRoot: artifact })).status).toBe("installed"); + expect(await readFile(join(content, EVIDENCE_PLUGIN))).toEqual(await readFile(join(artifact, EVIDENCE_PLUGIN))); + const sibling = join(root, "other"); await mkdir(sibling); + await expect(installEvidencePlugin({ projectRoot: content, pluginRoot: sibling, artifactRoot: artifact })).rejects.toThrow("ancestor"); + }); + test("upgrades only a recognized official digest", async () => { + const root = await fixture(); const bundle = join(root, ".tmp/bundle"); await mkdir(bundle, { recursive: true }); + const bytes = await readFile(join(artifact, EVIDENCE_PLUGIN)); + await writeFile(join(bundle, EVIDENCE_PLUGIN), bytes); + const previous = Buffer.from("previous official artifact"); + const digest = (b: Buffer) => createHash("sha256").update(b).digest("hex"); + await writeFile(join(bundle, "manifest.json"), JSON.stringify({ sha256: digest(bytes), previous: [digest(previous)] })); + await writeFile(join(root, EVIDENCE_PLUGIN), previous); + expect((await installEvidencePlugin({ projectRoot: root, artifactRoot: bundle })).status).toBe("upgraded"); + expect(await readFile(join(root, EVIDENCE_PLUGIN))).toEqual(bytes); + }); +}); diff --git a/packages/context-cli/src/cli.ts b/packages/context-cli/src/cli.ts index 7e6b3c87..bb7173fd 100644 --- a/packages/context-cli/src/cli.ts +++ b/packages/context-cli/src/cli.ts @@ -45,6 +45,7 @@ import { registerProjectVerifyCommand, } from "./registerProjectLifecycleCommands.js"; import { registerPluginCommands } from "./registerPluginCommands.js"; +import { registerEvidenceCommands } from "./commands/evidenceCommands.js"; import { registerPackageCommands } from "./registerPackageCommands.js"; function inferErrorCategory(message: string): string { @@ -198,6 +199,7 @@ export function createCliProgram(): Command { registerProjectEntryCommand(program); registerProjectInitCommand(program); registerPluginCommands(program); + registerEvidenceCommands(program); registerDebugCommands(program); registerDocumentRevisionCommand(program); diff --git a/packages/context-cli/src/commands/evidenceCommands.ts b/packages/context-cli/src/commands/evidenceCommands.ts new file mode 100644 index 00000000..b3d53897 --- /dev/null +++ b/packages/context-cli/src/commands/evidenceCommands.ts @@ -0,0 +1,20 @@ +import type { Command } from "commander"; +import { resolve } from "node:path"; +import { installEvidencePlugin } from "../project/evidencePlugin.js"; + +export function registerEvidenceCommands(program: Command): void { + program.command("evidence").description("Manage the repository evidence Wasm plugin") + .command("install [project-dir]") + .description("Install or safely upgrade the bundled evidence plugin without starting production") + .option("--repository-root ", "explicit enclosing Git root for a nested workspace") + .option("--plugin-root ", "explicit host-registered content root inside the repository") + .option("--format ", "output format: json", "json") + .action(async (projectDir: string | undefined, options: { repositoryRoot?: string; pluginRoot?: string; format: string }) => { + if (options.format !== "json") throw new Error("--format must be json"); + const result = await installEvidencePlugin({ projectRoot: resolve(projectDir ?? "."), + ...(options.repositoryRoot === undefined ? {} : { repositoryRoot: options.repositoryRoot }), + ...(options.pluginRoot === undefined ? {} : { pluginRoot: options.pluginRoot }) }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (result.status === "conflict" || result.status === "needs-repository-root") process.exitCode = 1; + }); +} diff --git a/packages/context-cli/src/lib/pathFreeCommandMatrix.ts b/packages/context-cli/src/lib/pathFreeCommandMatrix.ts index 3871bd45..f2439396 100644 --- a/packages/context-cli/src/lib/pathFreeCommandMatrix.ts +++ b/packages/context-cli/src/lib/pathFreeCommandMatrix.ts @@ -7,6 +7,8 @@ export const COMMAND_MATRIX: readonly CommandMatrixEntry[] = [ { command: "entry", view: "production-semantic", handles: ["project_status", "workspace_root", "next_command"], notes: "Resolves the single agent entry into initialization, workspace relocation, or current Agent Graph workflow evaluation." }, { command: "init", view: "production-semantic", handles: ["project_dir", "project_name"], notes: "Creates a project-local Context workspace." }, { command: "plugin", view: "production-semantic", handles: ["plugin_status", "agent_adapter"], notes: "Global Context agent plugin namespace." }, + { command: "evidence", view: "production-semantic", handles: ["plugin_status"], notes: "Repository evidence plugin maintenance namespace." }, + { command: "evidence install", view: "production-semantic", handles: ["project_dir", "repository_root", "plugin_status"], notes: "Installs the bundled read-only evidence Wasm without starting production." }, { command: "plugin install", view: "production-semantic", handles: ["plugin_status", "agent_adapter", "marketplace_root"], notes: "Installs or refreshes bundled global agent plugins." }, { command: "plugin path", view: "production-semantic", handles: ["marketplace_root"], notes: "Prints the bundled plugin marketplace root." }, { command: "plugin status", view: "production-semantic", handles: ["plugin_status", "agent_adapter"], notes: "Reports global plugin installation state and stale entry cleanup." }, diff --git a/packages/context-cli/src/project/evidencePlugin.ts b/packages/context-cli/src/project/evidencePlugin.ts new file mode 100644 index 00000000..f665e136 --- /dev/null +++ b/packages/context-cli/src/project/evidencePlugin.ts @@ -0,0 +1,110 @@ +import { createHash, randomUUID } from "node:crypto"; +import { lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const EVIDENCE_PLUGIN = "context-evidence.sourcegraph.wasm"; +export interface EvidencePluginResult { + path: string; + status: "installed" | "unchanged" | "upgraded" | "conflict" | "needs-repository-root"; + message?: string; +} +const missing = (error: unknown) => !!error && typeof error === "object" && "code" in error && error.code === "ENOENT"; +const hash = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); + +async function gitRoot(directory: string): Promise { + let cursor = directory; + while (true) { + try { await lstat(join(cursor, ".git")); return cursor; } catch (error) { if (!missing(error)) throw error; } + const parent = dirname(cursor); + if (parent === cursor) return undefined; + cursor = parent; + } +} + +async function bundledEvidence(): Promise { + let cursor = dirname(fileURLToPath(import.meta.url)); + while (true) { + for (const candidate of [join(cursor, "evidence"), join(cursor, "dist/evidence")]) { + try { if ((await lstat(join(candidate, "manifest.json"))).isFile()) return candidate; } + catch (error) { if (!missing(error)) throw error; } + } + const parent = dirname(cursor); + if (parent === cursor) throw new Error("Bundled evidence plugin is missing; rebuild or reinstall the Context CLI."); + cursor = parent; + } +} + +async function readArtifact(artifact: string): Promise<{ bytes: Buffer; manifest: { sha256: string; previous: string[] } }> { + const bytes = await readFile(join(artifact, EVIDENCE_PLUGIN)); + const manifest = JSON.parse(await readFile(join(artifact, "manifest.json"), "utf8")) as { sha256: string; previous: string[] }; + if (hash(bytes) !== manifest.sha256 || !Array.isArray(manifest.previous) || !manifest.previous.every(value => /^[a-f0-9]{64}$/u.test(value))) { + throw new Error("Invalid bundled evidence artifact manifest"); + } + const module = new WebAssembly.Module(bytes); + const metadata = WebAssembly.Module.customSections(module, "sourcegraph.plugin.v1"); + if (metadata.length !== 1 || JSON.parse(new TextDecoder().decode(metadata[0])).name !== "context-evidence") { + throw new Error("Invalid bundled evidence plugin metadata"); + } + return { bytes, manifest }; +} + +/** Does not execute project configuration, Git hooks or source material. */ +export async function installEvidencePlugin(input: { + projectRoot: string; + repositoryRoot?: string; + pluginRoot?: string; + artifactRoot?: string; +}): Promise { + const project = await realpath(resolve(input.projectRoot)); + const detected = await gitRoot(project); + const requested = input.repositoryRoot === undefined ? undefined : await realpath(resolve(input.repositoryRoot)); + const scoped = input.pluginRoot === undefined ? undefined : await realpath(resolve(input.pluginRoot)); + if (requested && scoped) throw new Error("Choose --repository-root or --plugin-root, not both."); + if (scoped) { + const fromGit = relative(detected ?? project, scoped); + const toProject = relative(scoped, project); + if ([fromGit, toProject].some(path => path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path))) { + throw new Error("--plugin-root must be an ancestor of the workspace inside the same repository."); + } + } + if (requested !== undefined && requested !== (detected ?? project)) { + throw new Error("--repository-root must be the actual enclosing Git root (or workspace root before Git initialization)."); + } + if (detected && detected !== project && requested === undefined && scoped === undefined) { + return { path: join(detected, EVIDENCE_PLUGIN), status: "needs-repository-root", + message: "Nested workspace: explicitly choose --repository-root for full-repository hosting or --plugin-root for the host's registered content root." }; + } + const root = scoped ?? requested ?? project; + const target = join(root, EVIDENCE_PLUGIN); + const { bytes, manifest } = await readArtifact(input.artifactRoot ?? await bundledEvidence()); + let original: Buffer | undefined; + try { + const stat = await lstat(target); + if (!stat.isFile() || stat.isSymbolicLink()) return { path: target, status: "conflict", message: "Existing plugin is not a regular file; preserved." }; + original = await readFile(target); + } catch (error) { if (!missing(error)) throw error; } + if (original && hash(original) === manifest.sha256) return { path: target, status: "unchanged" }; + if (original && !manifest.previous.includes(hash(original))) return { path: target, status: "conflict", message: "Unknown or modified plugin preserved." }; + if (!original) { + try { await writeFile(target, bytes, { flag: "wx" }); } + catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") return { path: target, status: "conflict", message: "Plugin was created concurrently; preserved." }; + throw error; + } + } else { + // Stage an atomic replacement on the same filesystem; recheck ownership before rename. + const scratch = join(project, ".tmp/evidence-install"); + await mkdir(scratch, { recursive: true }); + const temp = join(scratch, `${randomUUID()}.wasm`); + await writeFile(temp, bytes, { flag: "wx" }); + try { + if ((await lstat(target)).isSymbolicLink() || hash(await readFile(target)) !== hash(original)) { + return { path: target, status: "conflict", message: "Plugin changed during upgrade; preserved." }; + } + await rename(temp, target); + } finally { await unlink(temp).catch(error => { if (!missing(error)) throw error; }); } + } + return { path: target, status: original ? "upgraded" : "installed", + ...(relative(root, project) ? { message: `Use workspace_root=${relative(root, project).split(sep).join("/")} when reading this workspace.` } : {}) }; +} diff --git a/packages/context-cli/src/project/indexerBaseContracts.ts b/packages/context-cli/src/project/indexerBaseContracts.ts index 61b6d0a5..abff066b 100644 --- a/packages/context-cli/src/project/indexerBaseContracts.ts +++ b/packages/context-cli/src/project/indexerBaseContracts.ts @@ -21,7 +21,7 @@ import { bundledMarkdownReaderQuestionContracts } from "./indexerBaseMarkdownAuthoringCatalog.js"; const BASE_CONTRACT_VERSION = "1.1.0"; -export const BUNDLED_INDEXER_PARSER_PACKAGE_VERSION = "0.7.40"; +export const BUNDLED_INDEXER_PARSER_PACKAGE_VERSION = "0.7.42"; const BUNDLED_PARSER_REQUIREMENTS = buildIndexerParserCapabilityRequirements( BUNDLED_INDEXER_PARSER_PACKAGE_VERSION, ); diff --git a/packages/context-cli/src/project/workspace.ts b/packages/context-cli/src/project/workspace.ts index cfb2a77c..9b5842e6 100644 --- a/packages/context-cli/src/project/workspace.ts +++ b/packages/context-cli/src/project/workspace.ts @@ -15,6 +15,7 @@ import { import { enableContextDebug } from "./debugTrace.js"; import { renderAgents, renderProjectEntry, renderReadme } from "./workspaceGuidanceTemplates.js"; import { assertTrustedContextProjectConfigBoundary } from "./projectModulePolicy.js"; +import { installEvidencePlugin, type EvidencePluginResult } from "./evidencePlugin.js"; const PROJECT_DIRS = ["src", "sources", "knowledge", "dist"] as const; const PROJECT_SCRATCH_DIRS = [join(".tmp", "agent-payloads")] as const; @@ -43,6 +44,7 @@ export interface ProjectInitResult { language: ProjectLanguage; created: string[]; kept: string[]; + evidencePlugin?: EvidencePluginResult; } interface StaticTemplateFile { @@ -515,6 +517,10 @@ export async function initContextProject(input: ProjectInitInput): Promise 0 ? [`- preserved existing files → ${result.kept.map((path) => path.replace(`${projectRoot}/`, "")).join(", ")}`] : []), diff --git a/packages/context-cli/src/project/workspaceGuidanceTemplates.ts b/packages/context-cli/src/project/workspaceGuidanceTemplates.ts index df5e33cb..e903e3d8 100644 --- a/packages/context-cli/src/project/workspaceGuidanceTemplates.ts +++ b/packages/context-cli/src/project/workspaceGuidanceTemplates.ts @@ -70,6 +70,7 @@ export function renderReadme(projectName: string, language: ProjectLanguage): st "- `src/package-templates/`:输出包模板。", "- `sources/`:来源注册信息和采集快照。", "- `knowledge/`:持久知识及其结构投影。", + "- `context-evidence.sourcegraph.wasm`:仓库根目录的可选远程读取增强插件;存量仓库用 `context evidence install` 安全安装,查询时不自动安装。", "- `dist/`:生成的知识包。", "- `.tmp/agent-payloads/`:Agent 可选的临时命令输入;初始化时会创建,被清理后可在写入前重新创建。", "- `.tmp/install/`:依赖安装使用的工作区本地临时目录。", @@ -108,6 +109,7 @@ export function renderReadme(projectName: string, language: ProjectLanguage): st "- `src/package-templates/`: package output templates.", "- `sources/`: registered sources and captured snapshots.", "- `knowledge/`: durable approved knowledge and its structural projection with minimal closed source inputs.", + "- `context-evidence.sourcegraph.wasm`: optional repository-root remote read enhancement; use `context evidence install` to maintain it, never auto-install during queries.", "- `dist/`: generated packages.", "- `.tmp/agent-payloads/`: optional Agent-owned command inputs. Initialization creates it; recreate it before writing if scratch cleanup removed it.", "- `.tmp/install/`: workspace-local temporary files used during dependency installation.", diff --git a/packages/context-evidence-wasm/.gitignore b/packages/context-evidence-wasm/.gitignore new file mode 100644 index 00000000..b83d2226 --- /dev/null +++ b/packages/context-evidence-wasm/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/packages/context-evidence-wasm/Cargo.lock b/packages/context-evidence-wasm/Cargo.lock new file mode 100644 index 00000000..34700d52 --- /dev/null +++ b/packages/context-evidence-wasm/Cargo.lock @@ -0,0 +1,194 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "context-evidence-wasm" +version = "0.1.0" +dependencies = [ + "regex", + "serde", + "serde_json", + "serde_yaml_ng", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/context-evidence-wasm/Cargo.toml b/packages/context-evidence-wasm/Cargo.toml new file mode 100644 index 00000000..fbadc2f6 --- /dev/null +++ b/packages/context-evidence-wasm/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "context-evidence-wasm" +version = "0.1.0" +edition = "2021" +license = "MIT" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml_ng = "0.10" +regex = { version = "1", default-features = false, features = ["std", "unicode-perl"] } + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +panic = "abort" +strip = "debuginfo" diff --git a/packages/context-evidence-wasm/README.md b/packages/context-evidence-wasm/README.md new file mode 100644 index 00000000..8517125d --- /dev/null +++ b/packages/context-evidence-wasm/README.md @@ -0,0 +1,91 @@ +# Context evidence Wasm + +Read-only evidence enrichment for Context knowledge. The plugin joins existing +section markers, `knowledge/structure.yaml` and source registrations. It does not +verify source contents, search code, restore sources or modify knowledge. + +Build with `bun run --filter @c4a/context-cli build:evidence`. The build uses Rust +with the `wasm32-unknown-unknown` target and the committed Cargo lockfile. End users +receive the compiled artifact with the CLI and do not need Rust. +The binary embeds redistribution notices in the `context.licenses` custom section. + +## ABI 1 + +Exports: `memory`, `alloc(length: i32) -> i32`, +`dealloc(pointer: i32, length: i32)`, `enrich(pointer: i32, length: i32) -> i64`. +Pointers and lengths use unsigned 32-bit interpretation. Packed i64 values contain +the pointer in the high 32 bits and length in the low 32 bits. + +The host allocates and writes UTF-8 JSON input, invokes enrich, copies the JSON +object result, then deallocates both independent allocations with exact lengths. +Never reuse an instance after a trap, cancellation or invalid memory access. + +Imports from `sourcegraph`: + +- `read_file(path_pointer: i32, path_length: i32) -> i64`: raw file bytes in a + guest allocation made with alloc. The guest owns and releases this buffer. + Zero signals error (an empty successful file still has a nonzero pointer). +- `last_error() -> i64`: UTF-8 error in the same allocation convention. Diagnostics + are consumed but not echoed, to avoid leaking host details. + +The generic host may also provide read_files; this plugin does not require it. +All host reads must be bound to the invocation's repository, immutable revision +and readable scope, never accept a repo/revision from the guest, and enforce byte, +time and call limits. No WASI, network, filesystem or process capabilities. + +Input JSON: + +```json +{ + "operation": "read", + "args": {"workspace_root": ".", "include_digest": false}, + "files": [{"path": "knowledge/example.md", "start_line": 2, + "end_line": 8, "content": "actual returned text", "truncated": false}] +} +``` + +read_many uses the same envelope with multiple successfully returned files. Ranges +are actual returned ranges, not requested ranges. Extra generic envelope/file +fields are ignored; unknown plugin args are rejected. Empty args can be omitted +or null (a host's absent argument map). +For scoped repositories the host supplies files.path relative to the registered +plugin root, with root/repository_path as additional context. Host reads and +workspace_root use that same base. The host wraps results in scopes[{root,data}]; +prefix root only when resolving knowledge/snapshot locations, never external +source repository paths. A full-repository host uses the Git root. +Plugin metadata lives in custom section `sourcegraph.plugin.v1`. +The bundled metadata declares `default_enabled: true` for `read` and `read_many`. +Hosts supporting this declaration select the plugin when `plugins` is omitted; +an explicit `plugins: []` disables enhancement, and a nonempty list selects only +the named plugins. Hosts without default selection still require explicit +`plugins: [{"name":"context-evidence"}]`. Business arguments remain optional. + +Output contains `sections: [{path, section_id, references}]`, and only on failure +`issues: [{code, message, path?, section_id?, source_ref?}]`. No success flag. +References are recorded metadata, not proof that the source has been reread. +Repository references with a full 40/64-digit commit and a supported remote shape +return `{url}` (plus `content_digest` only when requested). They use Context's +blob-compatible web URL convention with encoded file segments and line anchors. +No host reachability is implied. Unsupported +remote shapes, known incompatible routes and nonimmutable refs retain the original +structured fields; a private host's web layout must support the blob convention. +Document and captured note/session evidence is unchanged. Ready source URLs do +not require another link-resolution call. For code drill-down, parse recognized +routes into the existing repository/revision/path/range API, not a new MCP URL API. +Repository paths already include registered subpath; do not prepend it twice. +Note/session sources use their committed dated files, not nonexistent registries. + +The host may pool instances only for the same repo, revision, artifact, effective +scope and configuration. Each instance is exclusive; parsed metadata/section +outlines are cached with bounded eviction. No output is retained between requests. + +## Local development verification + +`bun run --filter @c4a/context-cli test:evidence` builds the artifact and runs a +Node WebAssembly contract harness. This is development verification, not a local +query mode or a production sandbox. Sourcegraph service integration is separate. + +Before releasing a replacement artifact, add the SHA-256 of each previously +shipped official artifact to `official-digests.json`. The installer upgrades only +an exact known hash, never a filename or self-declared plugin version. Keep the +Rust toolchain and Cargo lockfile pinned; build remaps machine-specific paths. diff --git a/packages/context-evidence-wasm/fixtures/sections.json b/packages/context-evidence-wasm/fixtures/sections.json new file mode 100644 index 00000000..ce9ed23a --- /dev/null +++ b/packages/context-evidence-wasm/fixtures/sections.json @@ -0,0 +1,9 @@ +[ + {"text":"\nBody\n", "ids":["first"]}, + {"text":"```md\n\n```\n", "ids":[]}, + {"text":"\r\n~~~\r\n\r\n~~~\r\nBody\r\n", "ids":["a&b"]}, + {"text":"", "error":true}, + {"text":"", "error":true}, + {"text":"\n", "error":true}, + {"text":"", "error":true} +] diff --git a/packages/context-evidence-wasm/official-digests.json b/packages/context-evidence-wasm/official-digests.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/packages/context-evidence-wasm/official-digests.json @@ -0,0 +1 @@ +[] diff --git a/packages/context-evidence-wasm/plugin.json b/packages/context-evidence-wasm/plugin.json new file mode 100644 index 00000000..22782924 --- /dev/null +++ b/packages/context-evidence-wasm/plugin.json @@ -0,0 +1,17 @@ +{ + "name": "context-evidence", + "title": "Context evidence", + "description": "Attach recorded section sources to knowledge reads; does not verify original source contents.", + "version": "0.2.0", + "abi_version": 1, + "operations": ["read", "read_many"], + "default_enabled": true, + "input_schema": { + "type": "object", + "properties": { + "workspace_root": {"type": "string", "default": "."}, + "include_digest": {"type": "boolean", "default": false} + }, + "additionalProperties": false + } +} diff --git a/packages/context-evidence-wasm/rust-toolchain.toml b/packages/context-evidence-wasm/rust-toolchain.toml new file mode 100644 index 00000000..b00d0072 --- /dev/null +++ b/packages/context-evidence-wasm/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.89.0" +profile = "minimal" +targets = ["wasm32-unknown-unknown"] diff --git a/packages/context-evidence-wasm/src/abi.rs b/packages/context-evidence-wasm/src/abi.rs new file mode 100644 index 00000000..06c42487 --- /dev/null +++ b/packages/context-evidence-wasm/src/abi.rs @@ -0,0 +1,79 @@ +use crate::{Engine, Input, Reader, MAX_FILE}; +use serde_json::json; +use std::cell::RefCell; + +#[used] +#[link_section = "sourcegraph.plugin.v1"] +static METADATA: [u8; include_bytes!("../plugin.json").len()] = *include_bytes!("../plugin.json"); + +#[link(wasm_import_module = "sourcegraph")] +extern "C" { + fn read_file(path: u32, path_len: u32) -> u64; + fn last_error() -> u64; +} + +struct Host; +impl Reader for Host { + fn read(&mut self, path: &str) -> Result, String> { + let packed = unsafe { read_file(path.as_ptr() as u32, path.len() as u32) }; + if packed == 0 { + // Consume and release host diagnostics, but do not echo possible host + // paths or credentials into evidence. The requested path is reported. + let error = unsafe { last_error() }; + if error != 0 { + unsafe { dealloc((error >> 32) as u32, error as u32) }; + } + return Err("Host could not read the requested repository file".into()); + } + let pointer = (packed >> 32) as u32; + let length = packed as u32; + if length as usize > MAX_FILE { + unsafe { dealloc(pointer, length) }; + return Err("File exceeds plugin read budget".into()); + } + // The host allocates using our alloc export; take ownership without copying. + let bytes = unsafe { + Box::from_raw(std::ptr::slice_from_raw_parts_mut( + pointer as *mut u8, + length as usize, + )) + }; + Ok(bytes.into_vec()) + } +} + +thread_local! { static ENGINE: RefCell = RefCell::new(Engine::default()); } + +#[no_mangle] +pub extern "C" fn alloc(length: u32) -> u32 { + let buffer = vec![0u8; length as usize].into_boxed_slice(); + Box::into_raw(buffer) as *mut u8 as u32 +} + +/// Host must pass only live allocations with their original exact lengths. +#[no_mangle] +pub unsafe extern "C" fn dealloc(pointer: u32, length: u32) { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + pointer as *mut u8, + length as usize, + ))); +} + +/// Return packed u64: high 32 bits pointer, low 32 bits byte length. +/// Input and output are separately owned; host deallocates both after copying. +#[no_mangle] +pub unsafe extern "C" fn enrich(pointer: u32, length: u32) -> u64 { + let result = if length > 1024 * 1024 { + json!({"issues":[{"code":"INVALID_INPUT","message":"Input exceeds plugin budget"}]}) + } else { + let bytes = std::slice::from_raw_parts(pointer as *const u8, length as usize); + match serde_json::from_slice::(bytes) { + Ok(input) => ENGINE.with(|engine| engine.borrow_mut().enrich(input, &mut Host)), + Err(_) => json!({"issues":[{"code":"INVALID_INPUT","message":"Invalid plugin input"}]}), + } + }; + let bytes = serde_json::to_vec(&result).unwrap().into_boxed_slice(); + let length = bytes.len() as u64; + let pointer = Box::into_raw(bytes) as *mut u8 as u64; + (pointer << 32) | length +} diff --git a/packages/context-evidence-wasm/src/lib.rs b/packages/context-evidence-wasm/src/lib.rs new file mode 100644 index 00000000..a2c72814 --- /dev/null +++ b/packages/context-evidence-wasm/src/lib.rs @@ -0,0 +1,204 @@ +#[cfg(target_arch = "wasm32")] +mod abi; +mod sections; +mod sources; + +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; + +pub const MAX_FILE: usize = 8 * 1024 * 1024; +const MAX_OUTPUT: usize = 64 * 1024; +const MAX_CACHE: usize = 12 * 1024 * 1024; + +pub trait Reader { + fn read(&mut self, path: &str) -> Result, String>; +} + +#[derive(Deserialize)] +pub struct Input { + pub operation: String, + #[serde(default)] + pub args: Option, + pub files: Vec, +} +#[derive(Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct Args { + #[serde(default)] + pub workspace_root: Option, + #[serde(default)] + pub include_digest: bool, +} +#[derive(Deserialize)] +pub struct File { + pub path: String, + pub start_line: u64, + pub end_line: u64, + pub content: String, + #[serde(default)] + pub truncated: bool, +} + +#[derive(Default)] +pub struct Engine { + // The host MUST bind an instance to one immutable repository/revision/scope. + yaml: HashMap, + outlines: HashMap>, + cache_bytes: usize, +} + +pub fn safe_path(path: &str) -> bool { + !path.is_empty() + && !path.contains(['\\', ':']) + && !path.chars().any(char::is_control) + && path + .split('/') + .all(|p| !p.is_empty() && p != "." && p != "..") +} +fn join(root: &str, path: &str) -> String { + if root.is_empty() { + path.into() + } else { + format!("{root}/{path}") + } +} +fn text(reader: &mut impl Reader, path: &str) -> Result { + if !safe_path(path) { + return Err("Unsafe repository-relative path".into()); + } + let bytes = reader.read(path)?; + if bytes.len() > MAX_FILE { + return Err("File exceeds plugin read budget".into()); + } + String::from_utf8(bytes).map_err(|_| "File is not UTF-8".into()) +} +fn issue(code: &str, message: &str, path: &str) -> Value { + json!({"code": code, "message": message, "path": path}) +} + +impl Engine { + fn trim_cache(&mut self, size: usize) { + if self.cache_bytes.saturating_add(size) > MAX_CACHE { + self.yaml.clear(); + self.outlines.clear(); + self.cache_bytes = 0; + } + self.cache_bytes += size; + } + fn yaml(&mut self, reader: &mut impl Reader, path: &str) -> Result<&Value, String> { + if !self.yaml.contains_key(path) { + let content = text(reader, path)?; + // Reject duplicate YAML keys instead of replacing them in a JSON map. + // The YAML deserializer also limits depth and alias expansion work. + let yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content) + .map_err(|_| "Invalid YAML metadata".to_string())?; + let value: Value = + serde_json::to_value(yaml).map_err(|_| "Unsupported YAML metadata".to_string())?; + self.trim_cache(content.len().saturating_mul(2)); + self.yaml.insert(path.into(), value); + } + Ok(&self.yaml[path]) + } + pub fn enrich(&mut self, input: Input, reader: &mut impl Reader) -> Value { + let mut output = Vec::new(); + let mut issues = Vec::new(); + let args = input.args.unwrap_or_default(); + let root = args.workspace_root.as_deref().unwrap_or("."); + let root = if root == "." { "" } else { root }; + if (!root.is_empty() && !safe_path(root)) + || !matches!(input.operation.as_str(), "read" | "read_many") + || input.files.len() > 10 + { + return json!({"issues":[{"code":"INVALID_INPUT","message":"Invalid operation, workspace root or batch size"}]}); + } + let prefix = join(root, "knowledge/"); + let structure_path = join(root, "knowledge/structure.yaml"); + for file in input.files { + let Some(article_path) = file.path.strip_prefix(&prefix) else { + continue; + }; + if !article_path.ends_with(".md") { + continue; + } + let result = (|| -> Result, String> { + if !safe_path(&file.path) || file.start_line == 0 || file.end_line < file.start_line + { + return Err("Invalid returned file range".into()); + } + // Use full immutable Markdown only to locate markers. Never return + // unseen article text or treat the requested range as returned text. + if !self.outlines.contains_key(&file.path) { + let full = text(reader, &file.path)?; + let outline = sections::sections(&full)?; + self.trim_cache(full.len()); + self.outlines.insert(file.path.clone(), outline); + } + let selected: Vec<_> = self.outlines[&file.path] + .iter() + .filter(|s| s.start <= file.end_line && s.end >= file.start_line) + .cloned() + .collect(); + if selected.is_empty() { + return Ok(Vec::new()); + } + let structure = self.yaml(reader, &structure_path)?; + if structure["schema_version"] != "context.approved-structure.v1" { + return Err("Unsupported approved structure schema".into()); + } + let articles = structure["articles"] + .as_array() + .ok_or("Missing structure articles")?; + let matches: Vec<_> = articles + .iter() + .filter(|a| a["path"] == article_path) + .collect(); + if matches.len() != 1 { + return Err("Article structure is missing or ambiguous".into()); + } + let registered = matches[0]["sections"] + .as_array() + .ok_or("Missing structure sections")? + .clone(); + let mut results = Vec::new(); + for section in selected { + let matches: Vec<_> = registered + .iter() + .filter(|s| s["id"] == section.id) + .collect(); + if matches.len() != 1 { + issues.push(json!({"code":"SECTION_UNRESOLVED","message":"Section registration is missing or ambiguous","path":file.path,"section_id":section.id})); + continue; + } + let Some(refs) = matches[0]["references"].as_array() else { + return Err("Missing section references".into()); + }; + let mut references = Vec::new(); + let mut seen = HashSet::new(); + for reference in refs { + match self.reference(reader, root, reference, args.include_digest) { + Ok(value) => { if seen.insert(value.to_string()) { references.push(value); } }, + Err(message) => issues.push(json!({"code":"SOURCE_UNRESOLVED","message":message,"path":file.path,"section_id":section.id,"source_ref":reference["source_ref"]})), + } + } + results.push( + json!({"path":file.path,"section_id":section.id,"references":references}), + ); + } + Ok(results) + })(); + match result { + Ok(values) => output.extend(values), + Err(message) => issues.push(issue("EVIDENCE_UNAVAILABLE", &message, &file.path)), + } + } + let mut result = json!({"sections":output}); + if !issues.is_empty() { + result["issues"] = json!(issues); + } + if result.to_string().len() > MAX_OUTPUT { + return json!({"issues":[{"code":"OUTPUT_LIMIT","message":"Evidence exceeds output budget; read a narrower range"}]}); + } + result + } +} diff --git a/packages/context-evidence-wasm/src/sections.rs b/packages/context-evidence-wasm/src/sections.rs new file mode 100644 index 00000000..c0694aca --- /dev/null +++ b/packages/context-evidence-wasm/src/sections.rs @@ -0,0 +1,69 @@ +use regex::Regex; +use std::sync::OnceLock; + +#[derive(Clone, Debug)] +pub struct Section { + pub id: String, + pub start: u64, + pub end: u64, +} + +pub fn sections(text: &str) -> Result, String> { + static OPEN: OnceLock = OnceLock::new(); + static CLOSE: OnceLock = OnceLock::new(); + static INVALID: OnceLock = OnceLock::new(); + static FENCE: OnceLock = OnceLock::new(); + let open = OPEN.get_or_init(|| { + Regex::new(r#"^\s*\s*$"#).unwrap() + }); + let close = CLOSE.get_or_init(|| Regex::new(r"^\s*\s*$").unwrap()); + let invalid = INVALID.get_or_init(|| Regex::new(r"^\s*