diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c06d6c..86f1b0f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -156,11 +156,12 @@ jobs: version="${RELEASE_TAG#v}" asset_dir="${PRODUCT_NAME}-${TARGET}-v${version}" doc_dir="dist/${asset_dir}/share/doc/${PRODUCT_NAME}" - mkdir -p "dist/${asset_dir}/bin" "$doc_dir" + mkdir -p "dist/${asset_dir}/bin" "$doc_dir" "dist/${asset_dir}/share/${PRODUCT_NAME}" cp "target/${TARGET}/release/csgraph" "dist/${asset_dir}/bin/" cp "target/${TARGET}/release/code-system-graph-hooks" "dist/${asset_dir}/bin/" cp LICENSE NOTICE THIRD_PARTY_NOTICES.md README.md SECURITY.md "$doc_dir/" cp -R docs "$doc_dir/" + cp -R crates/code-system-graph-hooks/agent-integration-template "dist/${asset_dir}/share/${PRODUCT_NAME}/" cp scripts/install.sh scripts/uninstall.sh "dist/${asset_dir}/" tar -C dist -czf "dist/${asset_dir}.tgz" "${asset_dir}" rm -rf "dist/${asset_dir}" @@ -185,11 +186,13 @@ jobs: $assetDir = "$env:PRODUCT_NAME-$env:TARGET-v$version" $binDir = "dist\$assetDir\bin" $docDir = "dist\$assetDir\share\doc\$env:PRODUCT_NAME" - New-Item -ItemType Directory -Path $binDir, $docDir -Force + $shareDir = "dist\$assetDir\share\$env:PRODUCT_NAME" + New-Item -ItemType Directory -Path $binDir, $docDir, $shareDir -Force Copy-Item "target\$env:TARGET\release\csgraph.exe" $binDir Copy-Item "target\$env:TARGET\release\code-system-graph-hooks.exe" $binDir Copy-Item LICENSE, NOTICE, THIRD_PARTY_NOTICES.md, README.md, SECURITY.md $docDir Copy-Item docs "$docDir\docs" -Recurse + Copy-Item crates\code-system-graph-hooks\agent-integration-template "$shareDir\agent-integration-template" -Recurse Compress-Archive -Path "dist\$assetDir" -DestinationPath "dist\$assetDir.zip" -Force Remove-Item "dist\$assetDir" -Recurse -Force @@ -209,6 +212,64 @@ jobs: if (-not (Test-Path (Join-Path $binDir "code-system-graph-hooks.exe"))) { throw "code-system-graph-hooks.exe is missing from the archive" } + $template = Join-Path $extracted "$assetDir\share\$env:PRODUCT_NAME\agent-integration-template\agent-plugin\plugin.json" + if (-not (Test-Path $template)) { + throw "agent-integration-template is missing from the archive" + } + @( + "mcp.json", + "generated.gitignore", + "metadata\skill-description.txt", + "metadata\openai-display-name.txt", + "metadata\openai-default-prompt.txt", + "skills\code-system-graph\SKILL.md", + "skills\code-system-graph\references\operating-guide.md" + ) | ForEach-Object { + $templateFile = Join-Path (Split-Path -Parent $template) $_ + if (-not (Test-Path $templateFile)) { + throw "agent-integration-template is missing agent-plugin\$_" + } + } + if (-not (Test-Path (Join-Path (Split-Path -Parent (Split-Path -Parent $template)) "native-hooks\strict-gate.sh"))) { + throw "agent-integration-template is missing native-hooks\strict-gate.sh" + } + $smoke = Join-Path $env:RUNNER_TEMP "code-system-graph-plugin-smoke" + New-Item -ItemType Directory -Path (Join-Path $smoke "repo\src") -Force + Set-Content -Path (Join-Path $smoke "repo\src\lib.rs") -Value "pub fn smoke() {}" + Set-Content -Path (Join-Path $smoke "code-system-graph.yaml") -Value @" + version: 1 + name: release-plugin-smoke + repos: + app: + path: repo + "@ + $csgraph = Join-Path $binDir "csgraph.exe" + & $csgraph scan --config (Join-Path $smoke "code-system-graph.yaml") --database (Join-Path $smoke "graph.db") | Out-Null + & $csgraph plugin create --output (Join-Path $smoke "plugin") --config (Join-Path $smoke "code-system-graph.yaml") --database (Join-Path $smoke "graph.db") | Out-Null + if (-not (Test-Path (Join-Path $smoke "plugin\plugin.json"))) { + throw "generated plugin is missing plugin.json" + } + if (-not (Test-Path (Join-Path $smoke "plugin\.gitignore"))) { + throw "generated portable plugin is missing .gitignore" + } + if (-not (Test-Path (Join-Path $smoke "plugin\.local\code-system-graph\mcp-binding.json"))) { + throw "generated portable plugin is missing its local binding" + } + if (Get-ChildItem (Join-Path $smoke "plugin") -File -Recurse | Select-String -Pattern '\{\{[A-Z0-9_]+\}\}' -Quiet) { + throw "generated plugin contains an unresolved template variable" + } + $base = Join-Path $smoke "base-plugin" + New-Item -ItemType Directory -Path (Join-Path $base ".codex-plugin") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $base "skills\release-system-graph") -Force | Out-Null + Set-Content -Path (Join-Path $base "plugin.json") -Value '{"name":"release-base","version":"1.0.0","description":"Release smoke base"}' + Set-Content -Path (Join-Path $base "mcp.json") -Value '{"mcpServers":{"release-code-system-graph":{"type":"stdio","command":"csgraph","args":["mcp","--binding","${PLUGIN_ROOT}/.local/code-system-graph/mcp-binding.json"],"env":{"CODE_SYSTEM_GRAPH_MCP_ADMIN":"0"}}}}' + Set-Content -Path (Join-Path $base ".codex-plugin\plugin.json") -Value '{"name":"release-base","version":"1.0.0","mcpServers":{"release-code-system-graph":{"type":"stdio","command":"csgraph","args":["mcp","--binding","${PLUGIN_ROOT}/.local/code-system-graph/mcp-binding.json"],"env":{"CODE_SYSTEM_GRAPH_MCP_ADMIN":"0"}}}}' + Set-Content -Path (Join-Path $base ".gitignore") -Value '/.local/' + Set-Content -Path (Join-Path $base "skills\release-system-graph\SKILL.md") -Value "---`nname: release-system-graph`ndescription: Release smoke routing skill.`n---" + & $csgraph plugin create --output $base --mcp-server-name release-code-system-graph --routing-skill release-system-graph --config (Join-Path $smoke "code-system-graph.yaml") --database (Join-Path $smoke "graph.db") | Out-Null + if (-not (Test-Path (Join-Path $base ".local\code-system-graph\mcp-binding.json"))) { + throw "local MCP binding is missing" + } - name: Upload release asset uses: actions/upload-artifact@v7 diff --git a/CHANGELOG.md b/CHANGELOG.md index df9aae0..4e3aa5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,47 @@ All notable public changes to Code System Graph are documented in this file. Code System Graph follows Semantic Versioning. +## [1.0.3] - 2026-08-09 + +### Added + +- Added `executionPolicy.maxCodeGraphCorroborationAnchorsPerRepo`, retaining the 50-anchor default + while supporting smaller positive limits and explicit `-1` unlimited mode. +- Added opt-in per-repository `useGitignore` with workspace-over-local precedence, nested Git rule + semantics, observable configuration origins, and shared native, OpenAPI, and watch discovery. + Watched workspaces now reload ignore matchers and directory watches after enabled `.gitignore` + files change. +- Added `csgraph plugin create` and a versioned Agent Plugins 1.0.0 template for generating portable + read-only MCP packages with stable clone-independent identities, YAML-safe workspace metadata, + workspace-verifying routing guidance, and an ignored developer-local runtime binding. +- Added existing-plugin composition mode, `csgraph plugin uninstall`, and `csgraph mcp --binding`. + Managed MCP entries, routing skills, receipts, and local bindings can be installed and removed + without changing unrelated plugin components; modified or unowned content is never deleted. New + local bindings and receipts record the generating `csgraph` version, exact executable + fingerprint, and build-time source commit/dirty state when available. +- Complete plugins generate one client-neutral Agent Skill. Existing Codex plugins additionally + receive optional UI metadata in `agents/openai.yaml`; portable-only plugins do not. Every managed + skill file is validated and owned by the integration receipt. +- Native Claude Code, Codex, and Gemini prompt hooks now act only as intent-based skill selectors; + the packaged Agent Skill remains the single detailed MCP procedure. Cursor and Antigravity + project rules are documented as fallbacks when their clients cannot load that skill. +- Consolidated the portable Agent Plugin, canonical skill, native classifier signals, dynamic hook + guidance, static fallback rules, strict gate, and host-facing text under one visible + `agent-integration-template/` tree; Rust no longer carries editable routing prose or shell bodies. +- Reduced Agent Skill and hook prompt noise with task-oriented tool selection, progressive loading + of maintenance guidance, and narrower English and Spanish routing signals that ignore generic + coding prompts. +- Plugin creation and binding failures now preserve the standard CLI exit classifications for + invalid input, missing paths, conflicts, and internal failures. + +### Release engineering + +- Added schema validation, idempotency and conflict coverage, Unicode and space-path coverage, + YAML-frontmatter validation, stable exit-code coverage, local-binding ownership checks, MCP + handshakes for complete and existing-plugin profiles, and watched `.gitignore` reload coverage. +- Included the complete versioned Agent integration template tree in Unix and Windows release + archives and smoke validation. + ## [1.0.2] - 2026-08-05 ### Performance and reliability diff --git a/Cargo.lock b/Cargo.lock index 800fcc9..44d97df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,20 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +25,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -240,6 +260,21 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.1" @@ -260,6 +295,12 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bstr" version = "1.13.0" @@ -276,6 +317,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytes" version = "1.12.1" @@ -398,17 +445,19 @@ dependencies = [ [[package]] name = "code-system-graph" -version = "1.0.2" +version = "1.0.3" dependencies = [ "anyhow", "atomic-write-file", "axum", + "blake3", "clap", "clap_complete", "code-system-graph-core", "code-system-graph-hooks", "code-system-graph-model", "code-system-graph-store-sqlite", + "jsonschema", "nix 0.31.3", "notify", "reqwest", @@ -416,6 +465,7 @@ dependencies = [ "rusqlite", "schemars", "serde", + "serde-saphyr", "serde_json", "signal-hook", "subtle", @@ -431,7 +481,7 @@ dependencies = [ [[package]] name = "code-system-graph-core" -version = "1.0.2" +version = "1.0.3" dependencies = [ "async-trait", "atomic-write-file", @@ -440,6 +490,7 @@ dependencies = [ "globset", "graphql-parser", "hcl-rs", + "ignore", "libc", "nix 0.31.3", "proto-parser", @@ -478,7 +529,7 @@ dependencies = [ [[package]] name = "code-system-graph-hooks" -version = "1.0.2" +version = "1.0.3" dependencies = [ "atomic-write-file", "blake3", @@ -493,7 +544,7 @@ dependencies = [ [[package]] name = "code-system-graph-model" -version = "1.0.2" +version = "1.0.3" dependencies = [ "blake3", "camino", @@ -505,7 +556,7 @@ dependencies = [ [[package]] name = "code-system-graph-store-sqlite" -version = "1.0.2" +version = "1.0.3" dependencies = [ "blake3", "code-system-graph-model", @@ -566,6 +617,31 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "darling" version = "0.23.0" @@ -600,6 +676,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "deranged" version = "0.5.8" @@ -632,6 +714,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -678,6 +769,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476de73bddf2ef8490aa4ee8f1cf40b430bf1d56c48c22080e5186952cd580e6" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -690,6 +792,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -711,6 +824,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -843,9 +966,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -910,6 +1035,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -1205,6 +1332,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1325,6 +1468,58 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.49.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce93912abc8220a3fdb768b2c4a826a7a9a4b1599cfb4d760d422eaa1faf88c7" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.49.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8165657ebed4d32c50f3c250c1986d8428b16fbfeac355222e8fec50aa26eb1d" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.49.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b069c5fdda3e9c2242bba49d811d5bbdb488abd8d234ed44a6312fd2891113e1" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "num-cmp", + "num-traits", + "serde_json", +] + [[package]] name = "kqueue" version = "1.2.0" @@ -1355,6 +1550,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -1394,6 +1595,15 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.33" @@ -1418,6 +1628,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -1502,12 +1718,81 @@ dependencies = [ "winapi", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1563,6 +1848,35 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "pastey" version = "0.2.3" @@ -1828,6 +2142,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "ref-cast" version = "1.0.26" @@ -1848,6 +2171,23 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "referencing" +version = "0.49.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f39f4c36ce0f50e96fb740d895f1cad34cfa76c4ab5c36934d6590bcd7029087" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.13.1" @@ -1862,9 +2202,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2151,6 +2491,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "security-framework" version = "3.7.0" @@ -2402,6 +2748,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2826,6 +3193,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2879,6 +3252,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -2894,6 +3277,18 @@ dependencies = [ "serde", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 8575736..c9e4f41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ default-members = ["crates/*"] resolver = "3" [workspace.package] -version = "1.0.2" +version = "1.0.3" edition = "2024" rust-version = "1.97.1" description = "Local cross-repository code intelligence and dependency graph for impact analysis and AI coding agents." diff --git a/README.md b/README.md index 2bd0f07..8e197c0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Map APIs, events, schemas, packages, databases, and ownership across repositorie breaks another service. [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) -![Source version](https://img.shields.io/badge/source-v1.0.2-orange.svg) +![Source version](https://img.shields.io/badge/source-v1.0.3-orange.svg) [![crates.io](https://img.shields.io/crates/v/code-system-graph.svg)](https://crates.io/crates/code-system-graph) ![Platforms](https://img.shields.io/badge/validated-Linux%20%7C%20macOS%20%7C%20Windows-1793d1.svg) ![Privacy](https://img.shields.io/badge/privacy-local%20%7C%20no%20telemetry-2ea44f.svg) @@ -273,12 +273,77 @@ codex mcp add code-system-graph -- \ --database /absolute/path/to/my-project/.code-system-graph/code-system-graph.db ``` +For clients that support [Agent Plugins](https://agent-plugins.org/), generate one portable, +workspace-bound package plus its ignored local binding instead of configuring MCP and routing +guidance separately: + +```bash +csgraph plugin create \ + --output ./code-system-graph-agent-plugin \ + --config code-system-graph.yaml \ + --database .code-system-graph/code-system-graph.db \ + --codegraph +``` + +The visible `crates/code-system-graph-hooks/agent-integration-template/` directory is the sole +source for editable agent installation, skill, discovery, and activation content. `agent-plugin/` +owns the portable manifest, MCP declaration, canonical skill, operating guide, optional client +metadata, license, validation schemas, and +`/.local/` rule. `native-hooks/` owns classifier signals, dynamic selector guidance, static +fallback rules, strict-gate shell, limitations, and host UI text. Complete-plugin mode emits only +the portable core; client metadata is used only while composing into a plugin that already targets +that client. Rust includes and renders these files and contains no second policy or prompt body. + +The manifest, MCP declaration, skill, and guide contain no machine paths and can be versioned. Only +`.local/code-system-graph/mcp-binding.json` records the current developer's manifest, database, +plugin root, optional CodeGraph executable, and auditable generator build identity: version, exact +executable fingerprint, and build-time commit/dirty state when available. Do not commit `.local/`. +Other developers recreate their binding after cloning by running `plugin create --output +` with that plugin's `--mcp-server-name` and `--routing-skill`. + +If a team already distributes a portable plugin, keep its MCP declarations versioned and write only +the developer-local workspace binding under the ignored `.local/` tree: + +```bash +csgraph plugin create \ + --output ./team-agent-plugin \ + --mcp-server-name team-code-system-graph \ + --routing-skill team-system-graph \ + --config code-system-graph.yaml \ + --database .code-system-graph/code-system-graph.db \ + --codegraph +``` + +The portable and Codex MCP entries must invoke `csgraph mcp --binding +${PLUGIN_ROOT}/.local/code-system-graph/mcp-binding.json`; `create` validates those entries, the +existing routing skill, and the `/.local/` ignore rule without rewriting any of them. Install the +plugin root. Re-run with `--replace-generated` after local paths change; only a binding carrying +Code System Graph's recognized ownership identity can be replaced. Until local binding creation +runs, that MCP entry fails visibly while independent plugin skills and servers remain usable. + +The generated MCP is read-only and requires `csgraph 1.0.3` in `PATH`; it does not bundle binaries. +Its plugin, server, and skill share a stable name derived from the declared workspace name, so +clones produce the same versioned files. Install it project-locally for workspace-only activation; +the generated skill also requires the nearest manifest and MCP `status` to report that workspace. +For Claude Code, Codex, and Gemini, an optional native prompt hook can improve activation by asking +the client to use the installed skill. The hook stays brief; the skill remains the only detailed +procedure. Do not combine the skill with an always-on project rule. + +Use the same generated Agent Skill for every supported client. Do not copy its policy into a global +`AGENTS.md`, Cursor rule, or another always-on instruction. Agent Plugins clients load the packaged +skill directly; other clients need only a small native manifest/MCP adapter or, as a compatibility +fallback, one manual MCP registration plus the same skill. Agent Plugins does not define a single +cross-vendor marketplace. + `--codegraph` exposes bounded repository-local `explore` context through Code System Graph. Omit it when CodeGraph is not installed; `explore` then remains unavailable while the federated tools keep working. -Claude Code, Codex, Gemini CLI, Antigravity, and Cursor are supported. Each agent uses its own MCP -configuration format; optional routing hooks are a separate step. Follow +Claude Code, Codex, Gemini CLI, Antigravity, and Cursor are supported. Codex and Cursor can consume +the packaged Agent Plugin; the other clients currently use their native MCP/plugin format with the +same Agent Skill. Native prompt hooks can complement skill activation in Claude Code, Codex, and +Gemini. Static Cursor and Antigravity rules remain fallback mechanisms when a skill cannot load. +Follow [Connect an agent](docs/AGENT_SETUP.md) for exact commands, configuration files, verification, and limitations. @@ -310,7 +375,7 @@ Where is coverage incomplete or stale? | Detect sibling repositories or choose workspace aliases | Manual manifest configuration | | Publish updates after files or the manifest change | Manual with one-shot `scan` or `sync`; automatic while `sync --watch` is running | | Register the MCP server with an agent | One explicit agent-specific command or config | -| Install routing hooks | Optional, one explicit command per agent and repository | +| Install native routing | Optional skill selector for Claude/Codex/Gemini; static fallback for Cursor/Antigravity | | Enable CodeGraph enrichment | Optional | ## Configuration at a glance @@ -325,7 +390,7 @@ Where is coverage incomplete or stale? ### Optional - explicit OpenAPI paths or manual links when automatic evidence is insufficient; -- repository-specific `excludes` and `includeDefaults` discovery globs; +- repository-specific `excludes`, `includeDefaults`, and opt-in `useGitignore` discovery policy; - Git for local change, revision, and strict pre-commit analysis; - [CodeGraph](docs/CODEGRAPH_INTEGRATION.md) for repository-local source and symbol context; - GitHub or Bitbucket Cloud access for pull-request analysis; diff --git a/crates/code-system-graph-cli/Cargo.toml b/crates/code-system-graph-cli/Cargo.toml index a92ae87..50742f4 100644 --- a/crates/code-system-graph-cli/Cargo.toml +++ b/crates/code-system-graph-cli/Cargo.toml @@ -28,12 +28,13 @@ path = "src/main.rs" anyhow = "1.0.104" atomic-write-file = "0.3.0" axum = "0.8.9" +blake3 = "1.8.5" clap = { version = "4.6.4", features = ["derive"] } clap_complete = "4.6.8" -code-system-graph-core = { version = "1.0.2", path = "../code-system-graph-core" } -code-system-graph-hooks = { version = "1.0.2", path = "../code-system-graph-hooks" } -code-system-graph-model = { version = "1.0.2", path = "../code-system-graph-model" } -code-system-graph-store-sqlite = { version = "1.0.2", path = "../code-system-graph-store-sqlite" } +code-system-graph-core = { version = "1.0.3", path = "../code-system-graph-core" } +code-system-graph-hooks = { version = "1.0.3", path = "../code-system-graph-hooks" } +code-system-graph-model = { version = "1.0.3", path = "../code-system-graph-model" } +code-system-graph-store-sqlite = { version = "1.0.3", path = "../code-system-graph-store-sqlite" } rmcp = { version = "3.1.0", features = ["transport-io"] } notify = "8.2.0" rusqlite = { version = "0.40.1", features = ["bundled"] } @@ -63,6 +64,8 @@ windows-sys = { version = "0.61.2", features = [ workspace = true [dev-dependencies] +jsonschema = { version = "0.49.8", default-features = false } reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] } rusqlite = { version = "0.40.1", features = ["bundled"] } +serde-saphyr = "1.0.0" tempfile = "3.27.0" diff --git a/crates/code-system-graph-cli/build.rs b/crates/code-system-graph-cli/build.rs index 2dae583..20b6fc9 100644 --- a/crates/code-system-graph-cli/build.rs +++ b/crates/code-system-graph-cli/build.rs @@ -1,11 +1,95 @@ -//! Target-specific linker configuration for the `csgraph` executable. +//! Captures optional Git provenance for auditable local plugin installation metadata. + +use std::env; +use std::path::Path; +use std::process::Command; + +const COMMIT_ENV: &str = "CODE_SYSTEM_GRAPH_BUILD_GIT_COMMIT"; +const DIRTY_ENV: &str = "CODE_SYSTEM_GRAPH_BUILD_GIT_DIRTY"; fn main() { - if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") - && std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") + emit_target_linker_configuration(); + + println!("cargo:rerun-if-env-changed={COMMIT_ENV}"); + println!("cargo:rerun-if-env-changed={DIRTY_ENV}"); + + let manifest_dir = env::var_os("CARGO_MANIFEST_DIR").expect("Cargo sets CARGO_MANIFEST_DIR"); + let manifest_dir = Path::new(&manifest_dir); + let commit = env::var(COMMIT_ENV) + .ok() + .or_else(|| git_output(manifest_dir, &["rev-parse", "--verify", "HEAD"])); + if let Some(commit) = commit.filter(|value| valid_commit(value)) { + println!("cargo:rustc-env={COMMIT_ENV}={commit}"); + } + + let dirty = env::var(DIRTY_ENV).ok().or_else(|| { + git_output( + manifest_dir, + &["status", "--porcelain=v1", "--untracked-files=normal"], + ) + .map(|status| (!status.is_empty()).to_string()) + }); + if let Some(dirty) = dirty.filter(|value| matches!(value.as_str(), "true" | "false")) { + println!("cargo:rustc-env={DIRTY_ENV}={dirty}"); + } + + if let Some(git_dir) = git_output(manifest_dir, &["rev-parse", "--absolute-git-dir"]) { + println!("cargo:rerun-if-changed={git_dir}/HEAD"); + println!("cargo:rerun-if-changed={git_dir}/index"); + } + + emit_worktree_rerun_triggers(manifest_dir); +} + +fn emit_target_linker_configuration() { + if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") + && env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { // MSVC executables default to a 1 MiB main stack, which is insufficient for the // synchronous extraction worker entered from the async CLI dispatcher. println!("cargo:rustc-link-arg-bin=csgraph=/STACK:8388608"); } } + +fn emit_worktree_rerun_triggers(manifest_dir: &Path) { + let Some(worktree_root) = git_output(manifest_dir, &["rev-parse", "--show-toplevel"]) else { + return; + }; + let worktree_root = Path::new(&worktree_root); + let Some(paths) = git_output( + worktree_root, + &["ls-files", "--cached", "--others", "--exclude-standard"], + ) else { + return; + }; + + // Explicit Git metadata triggers do not observe unstaged edits. Watching every materialized + // tracked or untracked source path keeps the embedded dirty flag aligned with the binary that + // Cargo is compiling. Index changes cover newly staged paths, while edits that make a new + // source file reachable necessarily also touch an already watched tracked file. + for relative in paths.lines().filter(|path| !path.is_empty()) { + println!( + "cargo:rerun-if-changed={}", + worktree_root.join(relative).display() + ); + } +} + +fn git_output(directory: &Path, arguments: &[&str]) -> Option { + let output = Command::new("git") + .arg("-C") + .arg(directory) + .args(arguments) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout) + .ok() + .map(|value| value.trim().to_owned()) +} + +fn valid_commit(value: &str) -> bool { + matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} diff --git a/crates/code-system-graph-cli/src/agent_plugin.rs b/crates/code-system-graph-cli/src/agent_plugin.rs new file mode 100644 index 0000000..020746c --- /dev/null +++ b/crates/code-system-graph-cli/src/agent_plugin.rs @@ -0,0 +1,685 @@ +//! Portable Agent Plugin generation and developer-local workspace binding. + +mod composition; +mod filesystem; +mod render; + +use std::collections::BTreeMap; +use std::fs; +use std::io::Read as _; +use std::path::{Path, PathBuf}; + +use code_system_graph_core::ExitCode; +use code_system_graph_model::OverallFreshness; +pub use composition::uninstall_composed_integration; +use composition::{install_composed_integration, validate_composed_base}; +use filesystem::{ + replace_owned_atomically, verify_binding_ownership, verify_existing, write_new_atomically +}; +use render::{render_files, workspace_component_name}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ApplicationError, application_exit_code, status_workspace}; + +const BINDING_GENERATOR: &str = "csgraph plugin binding"; +const LEGACY_COMPOSE_GENERATOR: &str = "csgraph plugin compose"; +const BINDING_RELATIVE_PATH: &str = ".local/code-system-graph/mcp-binding.json"; +const INTEGRATION_RECEIPT_RELATIVE_PATH: &str = ".local/code-system-graph/plugin-integration.json"; + +/// Inputs accepted by `csgraph plugin create`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentPluginCreateRequest { + /// Workspace manifest to validate and bind. + pub config: PathBuf, + /// Existing graph database containing the workspace snapshot. + pub database: PathBuf, + /// Type-safe generation target. + pub target: AgentPluginCreateTarget, + /// Expose optional read-only CodeGraph-backed MCP tools. + pub codegraph: bool, + /// Optional absolute or resolvable `CodeGraph` executable. + pub codegraph_binary: Option, +} + +/// Mutually exclusive output modes accepted by [`create_agent_plugin`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentPluginCreateTarget { + /// Generate or verify a complete portable plugin directory. + Complete { + /// New or already-identical output directory. + output: PathBuf, + }, + /// Compose a managed integration into an existing portable plugin. + Existing { + /// Existing portable plugin root. + output: PathBuf, + /// MCP server entry to add or verify. + mcp_server_name: String, + /// Routing skill to add or verify. + routing_skill: String, + /// Replace an older local receipt and binding generated by `plugin create`. + replace_generated: bool, + }, +} + +/// Output mode selected by `csgraph plugin create`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AgentPluginCreateMode { + /// Generate or verify the complete portable plugin. + CompletePlugin, + /// Add or verify one managed integration in an existing plugin. + ExistingPlugin, +} + +/// Machine-readable report returned after plugin generation or verification. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AgentPluginCreateReport { + /// Report schema version. + pub schema_version: u8, + /// Whether a complete plugin or only an existing plugin binding was created. + pub mode: AgentPluginCreateMode, + /// Workspace bound into the plugin. + pub workspace: String, + /// Stable workspace-specific plugin identity. + pub plugin_name: String, + /// Stable workspace-specific MCP server entry name. + pub mcp_server_name: String, + /// Stable workspace-specific skill name. + pub skill_name: String, + /// Canonical directory containing the workspace manifest. + pub workspace_root: String, + /// Client-managed activation semantics available in Agent Plugins 1.0.0. + pub activation_scope: String, + /// Absolute graph database path stored in the ignored local binding. + pub database: String, + /// Absolute generated plugin directory. + pub output: String, + /// Absolute ignored runtime binding file. + pub binding: String, + /// Whether the generated MCP enables `CodeGraph`. + pub codegraph_enabled: bool, + /// Absolute `CodeGraph` executable, when explicitly configured. + pub codegraph_binary: Option, + /// Snapshot freshness observed during preflight. + pub snapshot_freshness: OverallFreshness, + /// Deterministic generated file list. + pub files: Vec, + /// Whether a new plugin or binding was written, or an owned binding was replaced. + pub changed: bool, +} + +/// Inputs accepted by `csgraph plugin uninstall` for a composed plugin. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentPluginUninstallRequest { + /// Existing portable plugin root. + pub output: PathBuf, + /// Managed MCP server entry to remove. + pub mcp_server_name: String, + /// Managed routing skill to remove. + pub routing_skill: String, +} + +/// Machine-readable report returned after removing a managed integration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AgentPluginUninstallReport { + /// Report schema version. + pub schema_version: u8, + /// Portable plugin name that retained ownership of all unrelated content. + pub plugin_name: String, + /// Removed MCP server entry. + pub mcp_server_name: String, + /// Removed routing skill. + pub skill_name: String, + /// Canonical plugin root. + pub output: String, + /// Deterministic list of removed managed paths. + pub removed: Vec, + /// Whether managed content was removed. + pub changed: bool, +} + +/// Local runtime policy consumed by `csgraph mcp --binding`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AgentPluginMcpBinding { + /// Binding format version. + pub schema_version: u8, + /// Ownership identity. + pub generator: String, + /// Exact build that materialized this local binding, when recorded by a recent generator. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generator_build: Option, + /// Portable plugin name that owns the binding. + pub base_plugin_name: String, + /// Canonical portable plugin root used when creating the binding. + pub base: String, + /// Workspace configured for the MCP process. + pub workspace: String, + /// Canonical workspace manifest used when creating the binding. + pub config: String, + /// Canonical graph database opened by the MCP process. + pub database: String, + /// Whether read-only `CodeGraph` enrichment is enabled. + pub codegraph_enabled: bool, + /// Canonical `CodeGraph` executable, when explicitly configured. + pub codegraph_binary: Option, +} + +/// Auditable identity of the `csgraph` executable that materialized local plugin state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AgentPluginGeneratorBuild { + /// Code System Graph package version. + pub version: String, + /// Source commit embedded at build time, when the build environment exposed one. + pub source_commit: Option, + /// Whether the source checkout was dirty when the executable was built, when known. + pub source_dirty: Option, + /// BLAKE3 fingerprint of the exact executable that ran `plugin create`. + pub binary_fingerprint: String, +} + +/// Failure while generating or binding an Agent Plugin. +#[derive(Debug, Error)] +pub enum AgentPluginError { + /// Manifest, database, or freshness validation failed. + #[error(transparent)] + Application(Box), + /// A required path could not be resolved. + #[error("failed to resolve `{path}`: {source}")] + Resolve { + /// Input path. + path: PathBuf, + /// Operating-system failure. + #[source] + source: std::io::Error, + }, + /// A path cannot be represented portably in JSON or Markdown. + #[error("path `{0}` is not valid Unicode")] + NonUnicodePath(PathBuf), + /// A canonical workspace manifest unexpectedly has no containing directory. + #[error("workspace manifest `{0}` has no containing workspace root")] + MissingWorkspaceRoot(PathBuf), + /// Existing path is not byte-for-byte identical to the requested generated content. + #[error("plugin path `{output}` conflicts with the requested content: {detail}")] + Conflict { + /// Output directory. + output: PathBuf, + /// Bounded conflict explanation. + detail: String, + }, + /// Plugin content could not be written atomically. + #[error("failed to write plugin content `{path}`: {source}")] + Write { + /// Affected path. + path: PathBuf, + /// Operating-system failure. + #[source] + source: std::io::Error, + }, + /// Generated JSON could not be serialized. + #[error("failed to serialize `{file}`: {source}")] + Json { + /// Generated file. + file: &'static str, + /// Serialization failure. + #[source] + source: serde_json::Error, + }, + /// Base plugin JSON is valid JSON but has an unsupported shape. + #[error("base plugin file `{file}` is invalid: {detail}")] + InvalidBase { + /// Relative plugin file. + file: &'static str, + /// Bounded validation explanation. + detail: String, + }, + /// A versioned Agent Plugin template is malformed or has an invalid variable contract. + #[error("agent plugin template `{file}` is invalid: {detail}")] + Template { + /// Template path relative to `agent-integration-template/agent-plugin`. + file: &'static str, + /// Bounded validation explanation. + detail: String, + }, + /// Command options select an incomplete or unsupported creation mode. + #[error("invalid plugin create request: {0}")] + InvalidRequest(String), +} + +impl From for AgentPluginError { + fn from(error: ApplicationError) -> Self { + Self::Application(Box::new(error)) + } +} + +/// Classifies plugin failures into stable process exit codes. +#[must_use] +pub fn agent_plugin_exit_code(error: &AgentPluginError) -> ExitCode { + match error { + AgentPluginError::Application(source) => application_exit_code(source), + AgentPluginError::Resolve { source, .. } + if source.kind() == std::io::ErrorKind::NotFound => + { + ExitCode::NotFound + } + AgentPluginError::Conflict { .. } => ExitCode::Conflict, + AgentPluginError::NonUnicodePath(_) + | AgentPluginError::InvalidBase { .. } + | AgentPluginError::Template { .. } + | AgentPluginError::InvalidRequest(_) + | AgentPluginError::Json { .. } => ExitCode::InvalidInput, + AgentPluginError::Resolve { .. } + | AgentPluginError::MissingWorkspaceRoot(_) + | AgentPluginError::Write { .. } => ExitCode::Internal, + } +} + +/// Creates or verifies an Agent Plugins 1.0.0 package for one workspace. +/// +/// Without integration options, the complete package is rendered in memory and new output is +/// assembled atomically. When both an MCP server and routing skill are specified, the output must +/// be an existing portable plugin. Missing managed MCP entries and skill files are added while +/// unrelated components are preserved. An ignored receipt enables safe removal with +/// [`uninstall_composed_integration`]. +/// +/// # Errors +/// +/// Returns [`AgentPluginError`] for an invalid manifest/database pair, unsafe existing output, +/// path resolution failure, or non-atomic write failure. +pub fn create_agent_plugin( + request: &AgentPluginCreateRequest, +) -> Result { + let config = canonicalize_file(&request.config)?; + let database = canonicalize_file(&request.database)?; + let codegraph_binary = request + .codegraph_binary + .as_ref() + .map(|path| canonicalize_file(path)) + .transpose()?; + if codegraph_binary.is_some() && !request.codegraph { + return Err(AgentPluginError::Template { + file: "mcp-binding.json", + detail: "an explicit CodeGraph binary requires the CodeGraph profile".to_owned(), + }); + } + let status = status_workspace(&config, &database)?; + let workspace_root = config + .parent() + .ok_or_else(|| AgentPluginError::MissingWorkspaceRoot(config.clone()))? + .to_path_buf(); + let generated_component = workspace_component_name(&status.workspace); + let (mode, output, plugin_name, mcp_server_name, skill_name, replace_generated) = + match &request.target { + AgentPluginCreateTarget::Existing { + output, + mcp_server_name: server, + routing_skill: skill, + replace_generated, + } => { + let output = canonicalize_directory(output)?; + let plugin_name = validate_composed_base(&output, server, skill)?; + ( + AgentPluginCreateMode::ExistingPlugin, + output, + plugin_name, + server.to_owned(), + skill.to_owned(), + *replace_generated, + ) + } + AgentPluginCreateTarget::Complete { output } => ( + AgentPluginCreateMode::CompletePlugin, + absolute_output_path(output)?, + generated_component.clone(), + generated_component.clone(), + generated_component, + false, + ), + }; + let binding = AgentPluginMcpBinding { + schema_version: 1, + generator: BINDING_GENERATOR.to_owned(), + generator_build: Some(generator_build_metadata()?), + base_plugin_name: plugin_name.clone(), + base: unicode_path(&output)?, + workspace: status.workspace.clone(), + config: unicode_path(&config)?, + database: unicode_path(&database)?, + codegraph_enabled: request.codegraph, + codegraph_binary: codegraph_binary.as_deref().map(unicode_path).transpose()?, + }; + let (reported_files, changed) = if mode == AgentPluginCreateMode::ExistingPlugin { + install_composed_integration( + &output, + &plugin_name, + &status.workspace, + &mcp_server_name, + &skill_name, + &binding, + replace_generated, + )? + } else { + let files = render_files(&status.workspace, &plugin_name, &binding)?; + let changed = if fs::symlink_metadata(&output).is_ok() { + verify_existing(&output, &files)?; + false + } else { + write_new_atomically(&output, &files)?; + true + }; + (files.keys().cloned().collect(), changed) + }; + + Ok(AgentPluginCreateReport { + schema_version: 1, + mode, + workspace: status.workspace, + plugin_name, + mcp_server_name, + skill_name, + workspace_root: unicode_path(&workspace_root)?, + activation_scope: "client_managed_project_local".to_owned(), + database: unicode_path(&database)?, + output: unicode_path(&output)?, + binding: unicode_path(&output.join(BINDING_RELATIVE_PATH))?, + codegraph_enabled: request.codegraph, + codegraph_binary: codegraph_binary.as_deref().map(unicode_path).transpose()?, + snapshot_freshness: status.freshness.overall, + files: reported_files, + changed, + }) +} + +fn generator_build_metadata() -> Result { + let executable = std::env::current_exe().map_err(|source| AgentPluginError::Resolve { + path: PathBuf::from(""), + source, + })?; + let mut file = fs::File::open(&executable).map_err(|source| AgentPluginError::Resolve { + path: executable.clone(), + source, + })?; + let mut hasher = blake3::Hasher::new(); + let mut buffer = [0_u8; 8 * 1024]; + loop { + let read = file + .read(&mut buffer) + .map_err(|source| AgentPluginError::Resolve { + path: executable.clone(), + source, + })?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(AgentPluginGeneratorBuild { + version: env!("CARGO_PKG_VERSION").to_owned(), + source_commit: option_env!("CODE_SYSTEM_GRAPH_BUILD_GIT_COMMIT").map(str::to_owned), + source_dirty: option_env!("CODE_SYSTEM_GRAPH_BUILD_GIT_DIRTY") + .and_then(|value| value.parse().ok()), + binary_fingerprint: format!("blake3:{}", hasher.finalize().to_hex()), + }) +} + +/// Loads and validates a local binding for `csgraph mcp --binding`. +/// +/// # Errors +/// +/// Returns [`AgentPluginError`] when the file is missing, malformed, not generated by the +/// supported local-binding contract, or references unavailable runtime files. +pub fn load_agent_plugin_mcp_binding( + path: &Path, +) -> Result { + require_regular_file(path, "binding must be a regular file, not a symlink")?; + let path = canonicalize_file(path)?; + let mut binding: AgentPluginMcpBinding = serde_json::from_slice(&fs::read(&path).map_err( + |source| AgentPluginError::Resolve { + path: path.clone(), + source, + }, + )?) + .map_err(|source| AgentPluginError::Json { + file: "mcp-binding.json", + source, + })?; + if binding.schema_version != 1 || !recognized_binding_generator(&binding.generator) { + return Err(conflict( + &path, + "binding ownership identity is not recognized", + )); + } + validate_component_name(&binding.base_plugin_name, "base plugin")?; + if binding.workspace.trim().is_empty() { + return Err(AgentPluginError::InvalidBase { + file: "mcp-binding.json", + detail: "`workspace` must be non-empty".to_owned(), + }); + } + binding.config = unicode_path(&canonicalize_file(Path::new(&binding.config))?)?; + binding.database = unicode_path(&canonicalize_file(Path::new(&binding.database))?)?; + binding.codegraph_binary = binding + .codegraph_binary + .as_deref() + .map(Path::new) + .map(canonicalize_file) + .transpose()? + .as_deref() + .map(unicode_path) + .transpose()?; + if binding.codegraph_binary.is_some() && !binding.codegraph_enabled { + return Err(AgentPluginError::InvalidBase { + file: "mcp-binding.json", + detail: "`codegraphBinary` requires `codegraphEnabled`".to_owned(), + }); + } + Ok(binding) +} + +fn binding_files( + binding: &AgentPluginMcpBinding, +) -> Result>, AgentPluginError> { + let mut files = BTreeMap::new(); + files.insert( + "mcp-binding.json".to_owned(), + pretty_json( + "mcp-binding.json", + &serde_json::to_value(binding).map_err(|source| AgentPluginError::Json { + file: "mcp-binding.json", + source, + })?, + )?, + ); + Ok(files) +} + +fn write_local_binding( + base: &Path, + files: &BTreeMap>, + replace_generated: bool, +) -> Result { + let local_parent = base.join(".local"); + ensure_local_parent(&local_parent)?; + let output = base.join(".local/code-system-graph"); + if fs::symlink_metadata(&output).is_err() { + write_new_atomically(&output, files)?; + return Ok(true); + } + + let metadata = fs::symlink_metadata(&output).map_err(|source| AgentPluginError::Resolve { + path: output.clone(), + source, + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(conflict( + &output, + "generated binding root must be a real directory", + )); + } + match verify_existing(&output, files) { + Ok(()) => Ok(false), + Err(_) if replace_generated => { + verify_binding_ownership(&output)?; + replace_owned_atomically(&output, files)?; + Ok(true) + } + Err(error) => Err(error), + } +} + +fn recognized_binding_generator(generator: &str) -> bool { + generator == BINDING_GENERATOR || generator == LEGACY_COMPOSE_GENERATOR +} + +fn read_json_file(path: &Path, file: &'static str) -> Result { + serde_json::from_slice(&fs::read(path).map_err(|source| AgentPluginError::Resolve { + path: path.to_path_buf(), + source, + })?) + .map_err(|source| AgentPluginError::Json { file, source }) +} + +fn require_regular_file(path: &Path, detail: &str) -> Result<(), AgentPluginError> { + let metadata = fs::symlink_metadata(path).map_err(|_| conflict(path, detail))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(conflict(path, detail)); + } + Ok(()) +} + +fn ensure_local_parent(path: &Path) -> Result<(), AgentPluginError> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + Err(conflict(path, "`.local` must be a real directory")) + } + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(path).map_err(|source| AgentPluginError::Write { + path: path.to_path_buf(), + source, + }) + } + Err(source) => Err(AgentPluginError::Resolve { + path: path.to_path_buf(), + source, + }), + } +} + +fn canonicalize_file(path: &Path) -> Result { + let canonical = fs::canonicalize(path).map_err(|source| AgentPluginError::Resolve { + path: path.to_path_buf(), + source, + })?; + let metadata = fs::metadata(&canonical).map_err(|source| AgentPluginError::Resolve { + path: path.to_path_buf(), + source, + })?; + if !metadata.is_file() { + return Err(AgentPluginError::Conflict { + output: path.to_path_buf(), + detail: "expected a regular file".to_owned(), + }); + } + Ok(canonical) +} + +fn absolute_output_path(path: &Path) -> Result { + if let Ok(metadata) = fs::symlink_metadata(path) { + if metadata.file_type().is_symlink() { + return Err(conflict(path, "output must not be a symlink")); + } + return fs::canonicalize(path).map_err(|source| AgentPluginError::Resolve { + path: path.to_path_buf(), + source, + }); + } + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let parent = fs::canonicalize(parent).map_err(|source| AgentPluginError::Resolve { + path: parent.to_path_buf(), + source, + })?; + let name = path.file_name().ok_or_else(|| AgentPluginError::Conflict { + output: path.to_path_buf(), + detail: "output must name a directory below an existing parent".to_owned(), + })?; + Ok(parent.join(name)) +} + +fn canonicalize_directory(path: &Path) -> Result { + let canonical = fs::canonicalize(path).map_err(|source| AgentPluginError::Resolve { + path: path.to_path_buf(), + source, + })?; + let metadata = fs::metadata(&canonical).map_err(|source| AgentPluginError::Resolve { + path: path.to_path_buf(), + source, + })?; + if !metadata.is_dir() { + return Err(conflict(path, "expected a directory")); + } + Ok(canonical) +} + +fn required_json_string<'a>( + document: &'a serde_json::Value, + file: &'static str, + field: &str, +) -> Result<&'a str, AgentPluginError> { + document + .get(field) + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| AgentPluginError::InvalidBase { + file, + detail: format!("`{field}` must be a non-empty string"), + }) +} + +fn validate_component_name(value: &str, kind: &str) -> Result<(), AgentPluginError> { + let valid = !value.is_empty() + && value.len() <= 64 + && value.starts_with(|character: char| character.is_ascii_lowercase()) + && value.ends_with(|character: char| { + character.is_ascii_lowercase() || character.is_ascii_digit() + }) + && value.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }); + if !valid { + return Err(AgentPluginError::InvalidBase { + file: "plugin.json", + detail: format!("{kind} `{value}` is not a valid lower-case component name"), + }); + } + Ok(()) +} + +fn pretty_json(file: &'static str, value: &serde_json::Value) -> Result, AgentPluginError> { + let mut bytes = serde_json::to_vec_pretty(value) + .map_err(|source| AgentPluginError::Json { file, source })?; + bytes.push(b'\n'); + Ok(bytes) +} + +fn conflict(output: &Path, detail: &str) -> AgentPluginError { + AgentPluginError::Conflict { + output: output.to_path_buf(), + detail: detail.to_owned(), + } +} + +fn unicode_path(path: &Path) -> Result { + path.to_str() + .map(ToOwned::to_owned) + .ok_or_else(|| AgentPluginError::NonUnicodePath(path.to_path_buf())) +} diff --git a/crates/code-system-graph-cli/src/agent_plugin/composition.rs b/crates/code-system-graph-cli/src/agent_plugin/composition.rs new file mode 100644 index 0000000..e7c952f --- /dev/null +++ b/crates/code-system-graph-cli/src/agent_plugin/composition.rs @@ -0,0 +1,699 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use code_system_graph_model::stable_id_bytes; +use serde::{Deserialize, Serialize}; + +use super::filesystem::{ + verify_binding_ownership, verify_existing, write_file_atomically, write_new_atomically +}; +use super::render::render_existing_integration; +use super::{ + AgentPluginError, AgentPluginGeneratorBuild, AgentPluginMcpBinding, AgentPluginUninstallReport, AgentPluginUninstallRequest, BINDING_RELATIVE_PATH, INTEGRATION_RECEIPT_RELATIVE_PATH, canonicalize_directory, conflict, pretty_json, read_json_file, required_json_string, unicode_path, validate_component_name, write_local_binding +}; + +const RECEIPT_GENERATOR: &str = "csgraph plugin integration"; +const MANAGED_FILE_HASH_NAMESPACE: &str = "agent-plugin-integration-file-v1"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct IntegrationReceipt { + schema_version: u8, + generator: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + generator_build: Option, + base_plugin_name: String, + workspace: String, + mcp_server_name: String, + routing_skill: String, + managed_documents: Vec, + managed_files: BTreeMap, + managed_local_files: BTreeMap, +} + +struct DocumentUpdate { + path: PathBuf, + original: Vec, + updated: Vec, +} + +struct StagedRemoval { + skill_root: PathBuf, + skill_backup: PathBuf, + local_root: PathBuf, + local_backup: PathBuf, +} + +pub(super) fn validate_composed_base( + base: &Path, + mcp_server_name: &str, + routing_skill: &str, +) -> Result { + validate_component_name(mcp_server_name, "MCP server")?; + validate_component_name(routing_skill, "routing skill")?; + let plugin = read_json_file(&base.join("plugin.json"), "plugin.json")?; + let plugin_name = required_json_string(&plugin, "plugin.json", "name")?.to_owned(); + validate_component_name(&plugin_name, "base plugin")?; + let ignore_path = base.join(".gitignore"); + let ignore = fs::read_to_string(&ignore_path).map_err(|source| AgentPluginError::Resolve { + path: ignore_path, + source, + })?; + if !ignore.lines().any(|line| line.trim() == "/.local/") { + return Err(AgentPluginError::InvalidBase { + file: ".gitignore", + detail: "portable existing-plugin integration requires the exact `/.local/` rule" + .to_owned(), + }); + } + Ok(plugin_name) +} + +pub(super) fn install_composed_integration( + base: &Path, + plugin_name: &str, + workspace: &str, + mcp_server_name: &str, + routing_skill: &str, + binding: &AgentPluginMcpBinding, + replace_generated: bool, +) -> Result<(Vec, bool), AgentPluginError> { + let has_codex_manifest = base.join(".codex-plugin/plugin.json").is_file(); + let (server, skill_files) = render_existing_integration( + workspace, + mcp_server_name, + routing_skill, + has_codex_manifest, + )?; + let mut local_files = super::binding_files(binding)?; + let receipt = IntegrationReceipt { + schema_version: 1, + generator: RECEIPT_GENERATOR.to_owned(), + generator_build: binding.generator_build.clone(), + base_plugin_name: plugin_name.to_owned(), + workspace: workspace.to_owned(), + mcp_server_name: mcp_server_name.to_owned(), + routing_skill: routing_skill.to_owned(), + managed_documents: if has_codex_manifest { + vec![ + "mcp.json".to_owned(), + ".codex-plugin/plugin.json".to_owned(), + ] + } else { + vec!["mcp.json".to_owned()] + }, + managed_files: skill_files + .iter() + .map(|(path, contents)| { + ( + path.clone(), + stable_id_bytes(MANAGED_FILE_HASH_NAMESPACE, contents), + ) + }) + .collect(), + managed_local_files: local_files + .iter() + .map(|(path, contents)| { + ( + path.clone(), + stable_id_bytes(MANAGED_FILE_HASH_NAMESPACE, contents), + ) + }) + .collect(), + }; + local_files.insert( + "plugin-integration.json".to_owned(), + pretty_json( + "plugin-integration.json", + &serde_json::to_value(&receipt).map_err(|source| AgentPluginError::Json { + file: "plugin-integration.json", + source, + })?, + )?, + ); + + let document_updates = install_document_updates(base, mcp_server_name, &server)?; + let documents_changed = !document_updates.is_empty(); + let skill_root = base.join(format!("skills/{routing_skill}")); + let skill_subtree = skill_subtree(&skill_files, routing_skill)?; + let skill_exists = fs::symlink_metadata(&skill_root).is_ok(); + if skill_exists { + verify_existing(&skill_root, &skill_subtree)?; + } + + apply_document_updates(&document_updates)?; + if !skill_exists && let Err(error) = write_new_atomically(&skill_root, &skill_subtree) { + rollback_document_updates(&document_updates); + return Err(error); + } + let binding_changed = match write_local_binding(base, &local_files, replace_generated) { + Ok(changed) => changed, + Err(error) => { + if !skill_exists { + let _ = fs::remove_dir_all(&skill_root); + } + rollback_document_updates(&document_updates); + return Err(error); + } + }; + + let mut files = vec![ + "mcp.json".to_owned(), + BINDING_RELATIVE_PATH.to_owned(), + INTEGRATION_RECEIPT_RELATIVE_PATH.to_owned(), + ]; + if has_codex_manifest { + files.push(".codex-plugin/plugin.json".to_owned()); + } + files.extend(skill_files.keys().cloned()); + files.sort(); + Ok((files, documents_changed || !skill_exists || binding_changed)) +} + +/// Removes an unchanged integration previously installed into a composed Agent Plugin. +/// +/// # Errors +/// +/// Returns [`AgentPluginError`] when the ownership receipt is absent or invalid, a managed +/// component changed after installation, or the update cannot be completed safely. +pub fn uninstall_composed_integration( + request: &AgentPluginUninstallRequest, +) -> Result { + validate_component_name(&request.mcp_server_name, "MCP server")?; + validate_component_name(&request.routing_skill, "routing skill")?; + let base = canonicalize_directory(&request.output)?; + let (plugin_name, receipt) = load_owned_receipt(&base, request)?; + verify_binding_ownership(&base.join(".local/code-system-graph"))?; + verify_managed_local_files(&base, &receipt)?; + verify_managed_skill(&base, &receipt)?; + let (server, _) = render_existing_integration( + &receipt.workspace, + &receipt.mcp_server_name, + &receipt.routing_skill, + false, + )?; + + let document_updates = uninstall_document_updates( + &base, + &receipt.managed_documents, + &receipt.mcp_server_name, + &server, + )?; + let staged = stage_managed_removal(&base, &receipt.routing_skill)?; + if let Err(error) = apply_document_updates(&document_updates) { + staged.restore(); + return Err(error); + } + if let Err(error) = staged.commit() { + rollback_document_updates(&document_updates); + return Err(error); + } + + let mut removed = receipt.managed_files.keys().cloned().collect::>(); + removed.extend([ + BINDING_RELATIVE_PATH.to_owned(), + INTEGRATION_RECEIPT_RELATIVE_PATH.to_owned(), + ]); + for document in &receipt.managed_documents { + removed.push(format!( + "{document}#/{}/{}", + "mcpServers", receipt.mcp_server_name + )); + } + removed.sort(); + Ok(AgentPluginUninstallReport { + schema_version: 1, + plugin_name, + mcp_server_name: request.mcp_server_name.clone(), + skill_name: request.routing_skill.clone(), + output: unicode_path(&base)?, + removed, + changed: true, + }) +} + +fn merge_server( + bytes: &[u8], + file: &'static str, + name: &str, + expected: &serde_json::Value, +) -> Result>, AgentPluginError> { + let mut document: serde_json::Value = + serde_json::from_slice(bytes).map_err(|source| AgentPluginError::Json { file, source })?; + let servers = document + .get_mut("mcpServers") + .and_then(serde_json::Value::as_object_mut) + .ok_or_else(|| AgentPluginError::InvalidBase { + file, + detail: "`mcpServers` must be an object".to_owned(), + })?; + match servers.get(name) { + Some(actual) if actual == expected => Ok(None), + Some(_) => Err(AgentPluginError::InvalidBase { + file, + detail: format!("MCP server `{name}` conflicts with the managed integration"), + }), + None => { + servers.insert(name.to_owned(), expected.clone()); + Ok(Some(pretty_json(file, &document)?)) + } + } +} + +fn remove_server( + bytes: &[u8], + file: &'static str, + name: &str, + expected: &serde_json::Value, +) -> Result, AgentPluginError> { + let mut document: serde_json::Value = + serde_json::from_slice(bytes).map_err(|source| AgentPluginError::Json { file, source })?; + let servers = document + .get_mut("mcpServers") + .and_then(serde_json::Value::as_object_mut) + .ok_or_else(|| AgentPluginError::InvalidBase { + file, + detail: "`mcpServers` must be an object".to_owned(), + })?; + match servers.get(name) { + Some(actual) if actual == expected => { + servers.remove(name); + pretty_json(file, &document) + } + Some(_) => Err(conflict( + Path::new(file), + &format!("MCP server `{name}` changed after installation"), + )), + None => Err(conflict( + Path::new(file), + &format!("managed MCP server `{name}` is missing"), + )), + } +} + +fn install_document_updates( + base: &Path, + server_name: &str, + server: &serde_json::Value, +) -> Result, AgentPluginError> { + let mut updates = Vec::new(); + let portable_path = base.join("mcp.json"); + let portable = read_bytes(&portable_path)?; + if let Some(updated) = merge_server(&portable, "mcp.json", server_name, server)? { + updates.push(DocumentUpdate { + path: portable_path, + original: portable, + updated, + }); + } + let codex_path = base.join(".codex-plugin/plugin.json"); + if codex_path.exists() { + let codex = read_bytes(&codex_path)?; + if let Some(updated) = + merge_server(&codex, ".codex-plugin/plugin.json", server_name, server)? + { + updates.push(DocumentUpdate { + path: codex_path, + original: codex, + updated, + }); + } + } + Ok(updates) +} + +fn uninstall_document_updates( + base: &Path, + managed_documents: &[String], + server_name: &str, + server: &serde_json::Value, +) -> Result, AgentPluginError> { + let mut updates = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + for relative in managed_documents { + let file = match relative.as_str() { + "mcp.json" => "mcp.json", + ".codex-plugin/plugin.json" => ".codex-plugin/plugin.json", + _ => { + return Err(conflict( + base, + "integration receipt contains an unsupported managed document", + )); + } + }; + if !seen.insert(relative.as_str()) { + return Err(conflict( + base, + "integration receipt contains duplicate managed documents", + )); + } + let path = base.join(relative); + let original = read_bytes(&path)?; + let rewritten = remove_server(&original, file, server_name, server)?; + updates.push(DocumentUpdate { + path, + original, + updated: rewritten, + }); + } + if !seen.contains("mcp.json") { + return Err(conflict( + base, + "integration receipt does not own the portable MCP document", + )); + } + Ok(updates) +} + +fn apply_document_updates(updates: &[DocumentUpdate]) -> Result<(), AgentPluginError> { + for (index, update) in updates.iter().enumerate() { + if let Err(error) = write_file_atomically(&update.path, &update.updated) { + rollback_document_updates(&updates[..index]); + return Err(error); + } + } + Ok(()) +} + +fn rollback_document_updates(updates: &[DocumentUpdate]) { + for update in updates.iter().rev() { + let _ = write_file_atomically(&update.path, &update.original); + } +} + +fn load_owned_receipt( + base: &Path, + request: &AgentPluginUninstallRequest, +) -> Result<(String, IntegrationReceipt), AgentPluginError> { + let plugin = read_json_file(&base.join("plugin.json"), "plugin.json")?; + let plugin_name = required_json_string(&plugin, "plugin.json", "name")?.to_owned(); + let receipt_path = base.join(INTEGRATION_RECEIPT_RELATIVE_PATH); + let receipt: IntegrationReceipt = + serde_json::from_slice(&read_bytes(&receipt_path)?).map_err(|source| { + AgentPluginError::Json { + file: "plugin-integration.json", + source, + } + })?; + if receipt.schema_version != 1 + || receipt.generator != RECEIPT_GENERATOR + || receipt.base_plugin_name != plugin_name + || receipt.mcp_server_name != request.mcp_server_name + || receipt.routing_skill != request.routing_skill + { + return Err(conflict( + &receipt_path, + "integration receipt does not own the requested plugin components", + )); + } + Ok((plugin_name, receipt)) +} + +fn stage_managed_removal( + base: &Path, + routing_skill: &str, +) -> Result { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let staged = StagedRemoval { + skill_root: base.join(format!("skills/{routing_skill}")), + skill_backup: base.join(format!("skills/.{routing_skill}.uninstall-{nonce}")), + local_root: base.join(".local/code-system-graph"), + local_backup: base.join(format!(".local/.code-system-graph.uninstall-{nonce}")), + }; + fs::rename(&staged.skill_root, &staged.skill_backup).map_err(|source| { + AgentPluginError::Write { + path: staged.skill_root.clone(), + source, + } + })?; + if let Err(source) = fs::rename(&staged.local_root, &staged.local_backup) { + let _ = fs::rename(&staged.skill_backup, &staged.skill_root); + return Err(AgentPluginError::Write { + path: staged.local_root, + source, + }); + } + Ok(staged) +} + +impl StagedRemoval { + fn restore(self) { + let _ = fs::rename(self.skill_backup, self.skill_root); + let _ = fs::rename(self.local_backup, self.local_root); + } + + fn commit(self) -> Result<(), AgentPluginError> { + self.commit_with(|path| fs::remove_dir_all(path)) + } + + fn commit_with( + self, + mut remove: impl FnMut(&Path) -> std::io::Result<()>, + ) -> Result<(), AgentPluginError> { + let skill_restore = suffixed_path(&self.skill_backup, ".restore"); + let local_restore = suffixed_path(&self.local_backup, ".restore"); + if let Err(error) = copy_directory(&self.skill_backup, &skill_restore) { + self.restore(); + return Err(error); + } + if let Err(error) = copy_directory(&self.local_backup, &local_restore) { + let _ = fs::remove_dir_all(&skill_restore); + self.restore(); + return Err(error); + } + + for backup in [&self.skill_backup, &self.local_backup] { + if let Err(source) = remove(backup) { + let failed_path = backup.clone(); + restore_after_cleanup_failure(&self.skill_backup, &skill_restore, &self.skill_root); + restore_after_cleanup_failure(&self.local_backup, &local_restore, &self.local_root); + return Err(AgentPluginError::Write { + path: failed_path, + source, + }); + } + } + let _ = fs::remove_dir_all(skill_restore); + let _ = fs::remove_dir_all(local_restore); + Ok(()) + } +} + +fn copy_directory(source: &Path, destination: &Path) -> Result<(), AgentPluginError> { + fs::create_dir(destination).map_err(|source| AgentPluginError::Write { + path: destination.to_path_buf(), + source, + })?; + let result = (|| { + for entry in fs::read_dir(source).map_err(|error| AgentPluginError::Resolve { + path: source.to_path_buf(), + source: error, + })? { + let entry = entry.map_err(|error| AgentPluginError::Resolve { + path: source.to_path_buf(), + source: error, + })?; + let path = entry.path(); + let target = destination.join(entry.file_name()); + let metadata = + fs::symlink_metadata(&path).map_err(|source| AgentPluginError::Resolve { + path: path.clone(), + source, + })?; + if metadata.file_type().is_symlink() { + return Err(conflict(&path, "managed backups cannot contain symlinks")); + } + if metadata.is_dir() { + copy_directory(&path, &target)?; + } else if metadata.is_file() { + fs::copy(&path, &target).map_err(|source| AgentPluginError::Write { + path: target, + source, + })?; + } else { + return Err(conflict( + &path, + "managed backups must contain only regular files", + )); + } + } + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(destination); + } + result +} + +fn restore_after_cleanup_failure(backup: &Path, restore: &Path, target: &Path) { + if backup.exists() { + let failed = suffixed_path(backup, ".failed"); + if fs::rename(backup, &failed).is_ok() { + if fs::rename(restore, target).is_ok() { + let _ = fs::remove_dir_all(failed); + return; + } + let _ = fs::rename(failed, backup); + } + if fs::rename(backup, target).is_ok() { + let _ = fs::remove_dir_all(restore); + return; + } + } + let _ = fs::rename(restore, target); +} + +fn suffixed_path(path: &Path, suffix: &str) -> PathBuf { + let mut name = path + .file_name() + .expect("staged removal path has a file name") + .to_os_string(); + name.push(suffix); + path.with_file_name(name) +} + +fn read_bytes(path: &Path) -> Result, AgentPluginError> { + fs::read(path).map_err(|source| AgentPluginError::Resolve { + path: path.to_path_buf(), + source, + }) +} + +fn verify_managed_local_files( + base: &Path, + receipt: &IntegrationReceipt, +) -> Result<(), AgentPluginError> { + let local_root = base.join(".local/code-system-graph"); + let mut expected = BTreeMap::new(); + for (relative, expected_hash) in &receipt.managed_local_files { + if relative != "mcp-binding.json" { + return Err(conflict( + &local_root, + "integration receipt contains an unsupported managed local file", + )); + } + let path = local_root.join(relative); + let contents = read_bytes(&path)?; + if stable_id_bytes(MANAGED_FILE_HASH_NAMESPACE, &contents) != *expected_hash { + return Err(conflict( + &path, + "managed local binding changed after installation; refusing to delete it", + )); + } + expected.insert(relative.clone(), contents); + } + if !expected.contains_key("mcp-binding.json") { + return Err(conflict( + &local_root, + "integration receipt does not own the local MCP binding", + )); + } + expected.insert( + "plugin-integration.json".to_owned(), + read_bytes(&base.join(INTEGRATION_RECEIPT_RELATIVE_PATH))?, + ); + verify_existing(&local_root, &expected) +} + +fn skill_subtree( + files: &BTreeMap>, + skill_name: &str, +) -> Result>, AgentPluginError> { + let prefix = format!("skills/{skill_name}/"); + files + .iter() + .map(|(path, contents)| { + path.strip_prefix(&prefix) + .map(|relative| (relative.to_owned(), contents.clone())) + .ok_or_else(|| AgentPluginError::Template { + file: "skills/code-system-graph/SKILL.md", + detail: "managed skill path escaped its root".to_owned(), + }) + }) + .collect() +} + +fn verify_managed_skill(base: &Path, receipt: &IntegrationReceipt) -> Result<(), AgentPluginError> { + let prefix = format!("skills/{}/", receipt.routing_skill); + let mut files = BTreeMap::new(); + for (relative, expected_hash) in &receipt.managed_files { + let subtree = relative.strip_prefix(&prefix).ok_or_else(|| { + conflict( + base, + "integration receipt contains a path outside its skill root", + ) + })?; + let path = base.join(relative); + let contents = fs::read(&path).map_err(|source| AgentPluginError::Resolve { + path: path.clone(), + source, + })?; + if stable_id_bytes(MANAGED_FILE_HASH_NAMESPACE, &contents) != *expected_hash { + return Err(conflict( + &path, + "managed skill changed after installation; refusing to delete it", + )); + } + files.insert(subtree.to_owned(), contents); + } + verify_existing( + &base.join(format!("skills/{}", receipt.routing_skill)), + &files, + ) +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::fs; + + use super::StagedRemoval; + + #[test] + fn staged_removal_should_restore_both_trees_when_cleanup_fails() + -> Result<(), Box> { + let temporary = tempfile::tempdir()?; + let skill_root = temporary.path().join("skill"); + let skill_backup = temporary.path().join("skill.backup"); + let local_root = temporary.path().join("local"); + let local_backup = temporary.path().join("local.backup"); + fs::create_dir(&skill_backup)?; + fs::write(skill_backup.join("SKILL.md"), "managed skill\n")?; + fs::create_dir(&local_backup)?; + fs::write(local_backup.join("mcp-binding.json"), "managed binding\n")?; + let staged = StagedRemoval { + skill_root: skill_root.clone(), + skill_backup, + local_root: local_root.clone(), + local_backup, + }; + let calls = Cell::new(0_usize); + + let result = staged.commit_with(|path| { + let call = calls.get(); + calls.set(call + 1); + if call == 1 { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "injected cleanup failure", + )); + } + fs::remove_dir_all(path) + }); + + assert!(result.is_err()); + assert_eq!( + fs::read_to_string(skill_root.join("SKILL.md"))?, + "managed skill\n" + ); + assert_eq!( + fs::read_to_string(local_root.join("mcp-binding.json"))?, + "managed binding\n" + ); + Ok(()) + } +} diff --git a/crates/code-system-graph-cli/src/agent_plugin/filesystem.rs b/crates/code-system-graph-cli/src/agent_plugin/filesystem.rs new file mode 100644 index 0000000..934479d --- /dev/null +++ b/crates/code-system-graph-cli/src/agent_plugin/filesystem.rs @@ -0,0 +1,218 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::io::Write; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use atomic_write_file::AtomicWriteFile; + +use super::{AgentPluginError, AgentPluginMcpBinding, conflict, recognized_binding_generator}; + +pub(super) fn write_file_atomically(path: &Path, contents: &[u8]) -> Result<(), AgentPluginError> { + let mut file = AtomicWriteFile::open(path).map_err(|source| AgentPluginError::Write { + path: path.to_path_buf(), + source, + })?; + file.write_all(contents) + .map_err(|source| AgentPluginError::Write { + path: path.to_path_buf(), + source, + })?; + file.commit().map_err(|source| AgentPluginError::Write { + path: path.to_path_buf(), + source, + }) +} + +pub(super) fn verify_existing( + output: &Path, + expected: &BTreeMap>, +) -> Result<(), AgentPluginError> { + let metadata = fs::symlink_metadata(output).map_err(|source| AgentPluginError::Resolve { + path: output.to_path_buf(), + source, + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(conflict( + output, + "output is a symlink or is not a directory", + )); + } + let actual = collect_relative_files(output, output)?; + let expected_paths = expected_entries(expected); + if actual != expected_paths { + return Err(conflict( + output, + "files are missing, additional, or replaced by symlinks", + )); + } + for (relative, contents) in expected { + let path = output.join(relative); + let actual = fs::read(&path).map_err(|source| AgentPluginError::Resolve { + path: path.clone(), + source, + })?; + if actual != *contents { + return Err(conflict(output, &format!("`{relative}` differs"))); + } + } + Ok(()) +} + +fn expected_entries(files: &BTreeMap>) -> BTreeSet { + let mut entries = BTreeSet::new(); + for relative in files.keys() { + entries.insert(relative.clone()); + let mut parent = Path::new(relative).parent(); + while let Some(directory) = parent { + if directory.as_os_str().is_empty() { + break; + } + entries.insert(format!( + "{}/", + directory.to_string_lossy().replace('\\', "/") + )); + parent = directory.parent(); + } + } + entries +} + +fn collect_relative_files( + root: &Path, + directory: &Path, +) -> Result, AgentPluginError> { + let mut result = BTreeSet::new(); + let entries = fs::read_dir(directory).map_err(|source| AgentPluginError::Resolve { + path: directory.to_path_buf(), + source, + })?; + for entry in entries { + let entry = entry.map_err(|source| AgentPluginError::Resolve { + path: directory.to_path_buf(), + source, + })?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|source| AgentPluginError::Resolve { + path: path.clone(), + source, + })?; + if metadata.file_type().is_symlink() { + return Err(conflict(root, "symlinks are not allowed")); + } + if metadata.is_dir() { + let relative = path.strip_prefix(root).expect("child remains below root"); + result.insert(format!( + "{}/", + relative.to_string_lossy().replace('\\', "/") + )); + result.extend(collect_relative_files(root, &path)?); + } else if metadata.is_file() { + let relative = path.strip_prefix(root).expect("child remains below root"); + result.insert(relative.to_string_lossy().replace('\\', "/")); + } else { + return Err(conflict(root, "non-regular entries are not allowed")); + } + } + Ok(result) +} + +pub(super) fn write_new_atomically( + output: &Path, + files: &BTreeMap>, +) -> Result<(), AgentPluginError> { + let parent = output.parent().expect("absolute output has parent"); + let name = output + .file_name() + .expect("validated output name") + .to_string_lossy(); + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let temporary = parent.join(format!(".{name}.tmp-{}-{nonce}", std::process::id())); + fs::create_dir(&temporary).map_err(|source| AgentPluginError::Write { + path: temporary.clone(), + source, + })?; + let result = (|| { + for (relative, contents) in files { + let path = temporary.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| AgentPluginError::Write { + path: parent.to_path_buf(), + source, + })?; + } + fs::write(&path, contents).map_err(|source| AgentPluginError::Write { + path: path.clone(), + source, + })?; + } + fs::rename(&temporary, output).map_err(|source| AgentPluginError::Write { + path: output.to_path_buf(), + source, + }) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&temporary); + } + result +} + +pub(super) fn verify_binding_ownership(output: &Path) -> Result<(), AgentPluginError> { + let binding_path = output.join("mcp-binding.json"); + let metadata = fs::symlink_metadata(&binding_path) + .map_err(|_| conflict(output, "replacement requires an owned `mcp-binding.json`"))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(conflict(output, "binding ownership file is not regular")); + } + let binding: AgentPluginMcpBinding = + serde_json::from_slice(&fs::read(&binding_path).map_err(|source| { + AgentPluginError::Resolve { + path: binding_path, + source, + } + })?) + .map_err(|_| conflict(output, "binding ownership identity is malformed"))?; + if binding.schema_version != 1 || !recognized_binding_generator(&binding.generator) { + return Err(conflict( + output, + "binding ownership identity is unrecognized", + )); + } + Ok(()) +} + +pub(super) fn replace_owned_atomically( + output: &Path, + files: &BTreeMap>, +) -> Result<(), AgentPluginError> { + let parent = output.parent().expect("absolute output has parent"); + let name = output + .file_name() + .expect("validated output name") + .to_string_lossy(); + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let staged = parent.join(format!(".{name}.replace-{nonce}")); + let backup = parent.join(format!(".{name}.previous-{nonce}")); + write_new_atomically(&staged, files)?; + fs::rename(output, &backup).map_err(|source| AgentPluginError::Write { + path: output.to_path_buf(), + source, + })?; + if let Err(source) = fs::rename(&staged, output) { + let _ = fs::rename(&backup, output); + let _ = fs::remove_dir_all(&staged); + return Err(AgentPluginError::Write { + path: output.to_path_buf(), + source, + }); + } + fs::remove_dir_all(&backup).map_err(|source| AgentPluginError::Write { + path: backup, + source, + })?; + Ok(()) +} diff --git a/crates/code-system-graph-cli/src/agent_plugin/render.rs b/crates/code-system-graph-cli/src/agent_plugin/render.rs new file mode 100644 index 0000000..c1a6f25 --- /dev/null +++ b/crates/code-system-graph-cli/src/agent_plugin/render.rs @@ -0,0 +1,292 @@ +use std::borrow::Cow; +use std::collections::{BTreeMap, BTreeSet}; + +use code_system_graph_hooks::templates::{ + AGENT_PLUGIN_GITIGNORE, AGENT_PLUGIN_LICENSE, AGENT_PLUGIN_MANIFEST, AGENT_PLUGIN_MCP, AGENT_PLUGIN_OPENAI_DEFAULT_PROMPT, AGENT_PLUGIN_OPENAI_DISPLAY_NAME, AGENT_PLUGIN_OPENAI_METADATA, AGENT_PLUGIN_OPERATING_GUIDE, AGENT_PLUGIN_SKILL, AGENT_PLUGIN_SKILL_DESCRIPTION +}; +use code_system_graph_model::stable_id; + +use super::{AgentPluginError, AgentPluginMcpBinding, BINDING_RELATIVE_PATH, pretty_json}; + +const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub(super) fn render_files( + workspace: &str, + component_name: &str, + binding: &AgentPluginMcpBinding, +) -> Result>, AgentPluginError> { + let plugin = render_json_template( + "plugin.json", + AGENT_PLUGIN_MANIFEST, + &[ + ("PLUGIN_NAME", component_name.to_owned()), + ("PLUGIN_VERSION", PLUGIN_VERSION.to_owned()), + ("WORKSPACE_JSON_STRING", json_string_inner(workspace)?), + ], + )?; + let mcp = render_json_template( + "mcp.json", + AGENT_PLUGIN_MCP, + &[("MCP_SERVER_NAME", component_name.to_owned())], + )?; + let skill_files = render_skill_files(workspace, component_name, false)?; + + let mut files = BTreeMap::new(); + files.insert( + ".gitignore".to_owned(), + normalized_text_bytes(AGENT_PLUGIN_GITIGNORE), + ); + files.insert( + "LICENSE".to_owned(), + normalized_text_bytes(AGENT_PLUGIN_LICENSE), + ); + files.insert("mcp.json".to_owned(), mcp.into_bytes()); + files.insert("plugin.json".to_owned(), plugin.into_bytes()); + files.insert( + BINDING_RELATIVE_PATH.to_owned(), + pretty_json( + "mcp-binding.json", + &serde_json::to_value(binding).map_err(|source| AgentPluginError::Json { + file: "mcp-binding.json", + source, + })?, + )?, + ); + files.extend(skill_files); + Ok(files) +} + +pub(super) fn render_existing_integration( + workspace: &str, + mcp_server_name: &str, + skill_name: &str, + include_openai_metadata: bool, +) -> Result<(serde_json::Value, BTreeMap>), AgentPluginError> { + let mcp = render_json_template( + "mcp.json", + AGENT_PLUGIN_MCP, + &[("MCP_SERVER_NAME", mcp_server_name.to_owned())], + )?; + let document: serde_json::Value = + serde_json::from_str(&mcp).map_err(|source| AgentPluginError::Json { + file: "mcp.json", + source, + })?; + let server = document["mcpServers"][mcp_server_name].clone(); + Ok(( + server, + render_skill_files(workspace, skill_name, include_openai_metadata)?, + )) +} + +fn render_skill_files( + workspace: &str, + skill_name: &str, + include_openai_metadata: bool, +) -> Result>, AgentPluginError> { + let skill_description = render_template( + "metadata/skill-description.txt", + AGENT_PLUGIN_SKILL_DESCRIPTION, + &[("WORKSPACE", workspace.to_owned())], + ); + let skill_description = skill_description?.trim_end().to_owned(); + let skill = render_template( + "skills/code-system-graph/SKILL.md", + AGENT_PLUGIN_SKILL, + &[ + ("SKILL_NAME", skill_name.to_owned()), + ("SKILL_DESCRIPTION_YAML", json_value(&skill_description)?), + ("WORKSPACE", workspace.to_owned()), + ], + )?; + let operating_guide = render_template( + "skills/code-system-graph/references/operating-guide.md", + AGENT_PLUGIN_OPERATING_GUIDE, + &[], + )?; + let skill_root = format!("skills/{skill_name}"); + let mut files = BTreeMap::from([ + (format!("{skill_root}/SKILL.md"), skill.into_bytes()), + ( + format!("{skill_root}/references/operating-guide.md"), + operating_guide.into_bytes(), + ), + ]); + if include_openai_metadata { + let display_name = render_template( + "metadata/openai-display-name.txt", + AGENT_PLUGIN_OPENAI_DISPLAY_NAME, + &[("WORKSPACE", workspace.to_owned())], + )?; + let default_prompt = render_template( + "metadata/openai-default-prompt.txt", + AGENT_PLUGIN_OPENAI_DEFAULT_PROMPT, + &[("SKILL_NAME", skill_name.to_owned())], + )?; + let openai_metadata = render_template( + "skills/code-system-graph/agents/openai.yaml", + AGENT_PLUGIN_OPENAI_METADATA, + &[ + ( + "SKILL_DISPLAY_NAME_YAML", + json_value(display_name.trim_end())?, + ), + ( + "SKILL_DEFAULT_PROMPT_YAML", + json_value(default_prompt.trim_end())?, + ), + ], + )?; + files.insert( + format!("{skill_root}/agents/openai.yaml"), + openai_metadata.into_bytes(), + ); + } + Ok(files) +} + +fn render_json_template( + file: &'static str, + template: &str, + variables: &[(&'static str, String)], +) -> Result { + let rendered = render_template(file, template, variables)?; + let _: serde_json::Value = serde_json::from_str(&rendered) + .map_err(|source| AgentPluginError::Json { file, source })?; + Ok(rendered) +} + +fn render_template( + file: &'static str, + template: &str, + variables: &[(&'static str, String)], +) -> Result { + let template = normalize_line_endings(template); + let template = template.as_ref(); + let mut values = BTreeMap::new(); + for (name, value) in variables { + if values.insert(*name, value.as_str()).is_some() { + return Err(template_error( + file, + &format!("duplicate variable `{name}`"), + )); + } + } + + let mut rendered = String::with_capacity(template.len()); + let mut remaining = template; + let mut used = BTreeSet::new(); + while let Some(open) = remaining.find("{{") { + rendered.push_str(&remaining[..open]); + let placeholder = &remaining[open + 2..]; + let close = placeholder + .find("}}") + .ok_or_else(|| template_error(file, "unclosed variable"))?; + let name = &placeholder[..close]; + let value = values + .get(name) + .ok_or_else(|| template_error(file, &format!("unknown variable `{name}`")))?; + rendered.push_str(value); + used.insert(name); + remaining = &placeholder[close + 2..]; + } + if remaining.contains("}}") { + return Err(template_error( + file, + "closing delimiter without an opening delimiter", + )); + } + rendered.push_str(remaining); + let unused = values + .keys() + .copied() + .filter(|name| !used.contains(name)) + .collect::>(); + if !unused.is_empty() { + return Err(template_error( + file, + &format!("unused variables: {}", unused.join(", ")), + )); + } + Ok(rendered) +} + +fn normalized_text_bytes(value: &str) -> Vec { + normalize_line_endings(value).into_owned().into_bytes() +} + +fn normalize_line_endings(value: &str) -> Cow<'_, str> { + if value.contains('\r') { + Cow::Owned(value.replace("\r\n", "\n").replace('\r', "\n")) + } else { + Cow::Borrowed(value) + } +} + +fn template_error(file: &'static str, detail: &str) -> AgentPluginError { + AgentPluginError::Template { + file, + detail: detail.to_owned(), + } +} + +fn json_value(value: &str) -> Result { + serde_json::to_string(value).map_err(|source| AgentPluginError::Json { + file: "template variable", + source, + }) +} + +fn json_string_inner(value: &str) -> Result { + let encoded = json_value(value)?; + Ok(encoded[1..encoded.len() - 1].to_owned()) +} + +pub(super) fn workspace_component_name(workspace: &str) -> String { + let mut slug = workspace + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::(); + while slug.contains("--") { + slug = slug.replace("--", "-"); + } + slug = slug.trim_matches('-').to_owned(); + if slug.is_empty() { + slug.push_str("workspace"); + } + slug.truncate(30); + slug = slug.trim_matches('-').to_owned(); + let identity = stable_id("agent-plugin-workspace", workspace); + let digest = identity + .rsplit_once(':') + .map_or(identity.as_str(), |(_, digest)| digest); + let suffix = digest.chars().take(10).collect::(); + format!("code-system-graph-{slug}-{suffix}") +} + +#[cfg(test)] +mod tests { + use super::{normalize_line_endings, render_template}; + + #[test] + fn templates_should_render_with_portable_line_endings() { + let rendered = render_template( + "fixture.md", + "---\r\nname: {{NAME}}\r\ndescription: portable\r---\r", + &[("NAME", "example".to_owned())], + ) + .expect("template should render"); + + assert_eq!(rendered, "---\nname: example\ndescription: portable\n---\n"); + assert_eq!( + normalize_line_endings("already\nportable\n"), + "already\nportable\n" + ); + } +} diff --git a/crates/code-system-graph-cli/src/lib.rs b/crates/code-system-graph-cli/src/lib.rs index 23461b8..ed4849c 100644 --- a/crates/code-system-graph-cli/src/lib.rs +++ b/crates/code-system-graph-cli/src/lib.rs @@ -1,5 +1,6 @@ //! Delivery-layer orchestration shared by the `Code System Graph` CLI and MCP server. +mod agent_plugin; pub mod http_server; pub mod mcp; mod sync; @@ -14,12 +15,15 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +pub use agent_plugin::{ + AgentPluginCreateMode, AgentPluginCreateReport, AgentPluginCreateRequest, AgentPluginCreateTarget, AgentPluginError, AgentPluginMcpBinding, AgentPluginUninstallReport, AgentPluginUninstallRequest, agent_plugin_exit_code, create_agent_plugin, load_agent_plugin_mcp_binding, uninstall_composed_integration +}; use atomic_write_file::AtomicWriteFile; use code_system_graph_core::{ - AffectedTestsRequest, AnalyzerVersions, ArtifactKey, BatchAction, BatchPlanError, BitbucketProvider, ChangeAnalysisError, ChangeAnalysisOptions, ChangeError, ChangeImpactReport, ChangeProvider, ChangeRequest, ChangeScope, ChangeSet, CodeGraphConfig, CodeGraphProvider, CommunityError, ConfigDoctorInput, ConfigError, ConfigExtractionError, ContractReport, ContractRequest, CorroborationReport, DataDocument, DataExtractionError, DeclaredImplementation, DeclaredTestCase, DoctorReport, DoctorRequest, DocumentationDocument, DocumentationExtractionError, EXTRACTION_CONTRACT_VERSION, EffectiveRepositoryConfig, EventDocument, EventExtractionError, EventGraphFacts, ExecutionPolicy, ExitCode, ExportReport, ExportRequest, ExtractionBudgets, ExtractionGraphFacts, ExtractionLimitExceeded, ExtractionTracker, ExtractorBatch, ExtractorBatchPlan, FederatedGraph, FreshnessDoctorInput, GeneratedClientError, GeneratedClientMetadata, GitCliChangeProvider, GitHubProvider, GraphqlDocument, GraphqlExtractionError, GraphqlGraphFacts, HttpBoundary, HttpExtractionError, ImpactContext, ImpactError, ImpactReport, ImpactRequest, ImpactTarget, IncrementalPlan, InfrastructureDocument, InfrastructureExtractionError, IntegrityDoctorInput, InterfaceError, LinkError, LocalCodeIntelligenceProvider, LocalContextRequest, LocalContextResult, LocalEnrichmentInput, LocalEnrichmentStatus, LocalImpactItem, LocalImpactRequest, ManifestEdit, ManifestEditError, ManifestError, ManualLinkConfig, ManualLinkError, PackageGraphFacts, PackageManifest, PackageManifestError, PrAuthToken, ProtobufDocument, ProtobufExtractionError, ProtobufGraphFacts, ProviderBudget, ProviderCapability, ProviderDoctorInput, ProviderDoctorStatus, ProviderError, ProviderRequest, ProviderStatus, PullRequestCoordinates, PullRequestError, PullRequestInspectRequest, PullRequestInspection, PullRequestListPage, PullRequestListRequest, PullRequestListState, PullRequestProvider, PullRequestProviderConfig, PullRequestProviderKind, QueryError, RecommendedCommand, RegisteredWorkspace, RegistryError, ReqwestPrHttpTransport, SafeConfigDocument, SchemaDoctorInput, SearchFilters, SearchReport, SearchRequest, SourceEpistemicStatus, SourceGraphFacts, SourceLanguage, SourceObservation, SourceRole, SourceSyntaxError, SourceSyntaxLanguage, SourceWarning, SymbolAnchor, SymbolCorroboration, TraceError, TraversalReport, TraversalRequest, WorkspaceManifest, affected_link_keys, analyze_changes, analyze_communities_with_progress, analyze_impact, apply_openapi_override, classify_interface_error, commit_manifest_edit, compare_community_snapshots, corroborate_repository, declared_implementation, declared_test_case, doctor, documents_to_graph, encode_native_path, event_documents_to_graph, export_graph, extract_asyncapi, extract_codeowners, extract_data_artifact, extract_docker_compose, extract_generated_client_metadata, extract_graphql_document_with_tracker, extract_graphql_persisted_operations_with_tracker, extract_helm, extract_kubernetes, extract_markdown, extract_openapi_with_tracker, extract_package_manifest_with_tracker, extract_protobuf_with_tracker, extract_safe_config, extract_service_catalog, extract_terraform, graphql_documents_to_graph, inspect_contracts, inspect_source_syntax, link_declared_implementations_with_ambiguities, link_declared_tests_with_ambiguities, link_http_boundaries_with_ambiguities, link_registered_package_owners, load_extractor_batch_with_budgets, merge_affected_link_neighborhoods, package_manifest_to_graph, parse_event_source, parse_go_source_with_tracker, parse_graphql_source_with_tracker, parse_java_source_with_tracker, parse_javascript_source_at_path_with_tracker, parse_literal_sql_source_at_root, parse_manifest, parse_protobuf_generated_source, parse_python_source_with_tracker, parse_rust_source_with_tracker, parse_typescript_source_at_path_with_tracker, plan_extractor_batches, plan_incremental_scan, precheck_focused_source_values, preview_add_manual_link, preview_add_repository, preview_remove_repository, protobuf_documents_to_graph, register_workspace, resolve_manual_links, resolve_repository_config, search, source_observations_to_graph, store_extractor_batch, traverse + AffectedTestsRequest, AnalyzerVersions, ArtifactKey, BatchAction, BatchPlanError, BitbucketProvider, ChangeAnalysisError, ChangeAnalysisOptions, ChangeError, ChangeImpactReport, ChangeProvider, ChangeRequest, ChangeScope, ChangeSet, CodeGraphConfig, CodeGraphCorroborationAnchorLimit, CodeGraphProvider, CommunityError, ConfigDoctorInput, ConfigError, ConfigExtractionError, ContractReport, ContractRequest, CorroborationReport, DEFAULT_MAX_CODEGRAPH_CORROBORATION_ANCHORS_PER_REPO, DataDocument, DataExtractionError, DeclaredImplementation, DeclaredTestCase, DoctorReport, DoctorRequest, DocumentationDocument, DocumentationExtractionError, EXTRACTION_CONTRACT_VERSION, EffectiveRepositoryConfig, EventDocument, EventExtractionError, EventGraphFacts, ExecutionPolicy, ExitCode, ExportReport, ExportRequest, ExtractionBudgets, ExtractionGraphFacts, ExtractionLimitExceeded, ExtractionTracker, ExtractorBatch, ExtractorBatchPlan, FederatedGraph, FreshnessDoctorInput, GeneratedClientError, GeneratedClientMetadata, GitCliChangeProvider, GitHubProvider, GraphqlDocument, GraphqlExtractionError, GraphqlGraphFacts, HttpBoundary, HttpExtractionError, ImpactContext, ImpactError, ImpactReport, ImpactRequest, ImpactTarget, IncrementalPlan, InfrastructureDocument, InfrastructureExtractionError, IntegrityDoctorInput, InterfaceError, LinkError, LocalCodeIntelligenceProvider, LocalContextRequest, LocalContextResult, LocalEnrichmentInput, LocalEnrichmentStatus, LocalImpactItem, LocalImpactRequest, ManifestEdit, ManifestEditError, ManifestError, ManualLinkConfig, ManualLinkError, PackageGraphFacts, PackageManifest, PackageManifestError, PrAuthToken, ProtobufDocument, ProtobufExtractionError, ProtobufGraphFacts, ProviderBudget, ProviderCapability, ProviderDoctorInput, ProviderDoctorStatus, ProviderError, ProviderRequest, ProviderStatus, PullRequestCoordinates, PullRequestError, PullRequestInspectRequest, PullRequestInspection, PullRequestListPage, PullRequestListRequest, PullRequestListState, PullRequestProvider, PullRequestProviderConfig, PullRequestProviderKind, QueryError, RecommendedCommand, RegisteredWorkspace, RegistryError, ReqwestPrHttpTransport, SafeConfigDocument, SchemaDoctorInput, SearchFilters, SearchReport, SearchRequest, SourceEpistemicStatus, SourceGraphFacts, SourceLanguage, SourceObservation, SourceRole, SourceSyntaxError, SourceSyntaxLanguage, SourceWarning, SymbolAnchor, SymbolCorroboration, TraceError, TraversalReport, TraversalRequest, WorkspaceManifest, affected_link_keys, analyze_changes, analyze_communities_with_progress, analyze_impact, apply_openapi_override, classify_interface_error, commit_manifest_edit, compare_community_snapshots, corroborate_repository, declared_implementation, declared_test_case, doctor, documents_to_graph, encode_native_path, event_documents_to_graph, export_graph, extract_asyncapi, extract_codeowners, extract_data_artifact, extract_docker_compose, extract_generated_client_metadata, extract_graphql_document_with_tracker, extract_graphql_persisted_operations_with_tracker, extract_helm, extract_kubernetes, extract_markdown, extract_openapi_with_tracker, extract_package_manifest_with_tracker, extract_protobuf_with_tracker, extract_safe_config, extract_service_catalog, extract_terraform, graphql_documents_to_graph, inspect_contracts, inspect_source_syntax, link_declared_implementations_with_ambiguities, link_declared_tests_with_ambiguities, link_http_boundaries_with_ambiguities, link_registered_package_owners, load_extractor_batch_with_budgets, merge_affected_link_neighborhoods, package_manifest_to_graph, parse_event_source, parse_go_source_with_tracker, parse_graphql_source_with_tracker, parse_java_source_with_tracker, parse_javascript_source_at_path_with_tracker, parse_literal_sql_source_at_root, parse_manifest, parse_manifest_with_extensions, parse_protobuf_generated_source, parse_python_source_with_tracker, parse_rust_source_with_tracker, parse_typescript_source_at_path_with_tracker, plan_extractor_batches, plan_incremental_scan, precheck_focused_source_values, preview_add_manual_link, preview_add_repository, preview_remove_repository, protobuf_documents_to_graph, register_workspace, resolve_manual_links, resolve_repository_config, resolve_repository_config_with_use_gitignore, search, source_observations_to_graph, store_extractor_batch, traverse }; pub use code_system_graph_core::{ - ConfigSource, DEFAULT_EXCLUDES, IgnorePolicy, PROTECTED_EXCLUDES + ConfigSource, DEFAULT_EXCLUDES, IgnorePolicy, PROTECTED_EXCLUDES, discover_repository_files }; use code_system_graph_model::{ ArtifactFingerprint, CheckoutId, Community, CommunityAlgorithm, CommunityConfig, CommunityDelta, CommunityId, CommunityScope, Edge, EdgeId, EdgeKind, EpistemicStatus, Evidence, EvidenceId, ExtractorRun, ExtractorRunStatus, FreshnessSummary, LinkDecision, LinkStatus, Node, NodeId, NodeKind, OverallFreshness, Provenance, RepoFreshness, RepoFreshnessState, RepoId, RepositoryRecord, StoredExtractorBatch, ToolEnvelope, ToolStatus, TraceReport, WorkspaceRecord, stable_id, stable_id_bytes @@ -135,6 +139,9 @@ pub enum ApplicationError { /// Effective repository configuration could not be resolved. #[error(transparent)] Config(#[from] ConfigError), + /// Automatic repository discovery or an enabled `.gitignore` file failed. + #[error(transparent)] + Discovery(#[from] code_system_graph_core::RepositoryDiscoveryError), /// Workspace manifest mutation failed. #[error(transparent)] ManifestEdit(#[from] ManifestEditError), @@ -267,6 +274,7 @@ pub const fn application_exit_code(error: &ApplicationError) -> ExitCode { | ApplicationError::ManualLink(_) | ApplicationError::Registry(_) | ApplicationError::Config(_) + | ApplicationError::Discovery(_) | ApplicationError::ManifestEdit(_) | ApplicationError::UnknownOverrideRepository(_) | ApplicationError::WorkspaceNameMismatch { .. } @@ -397,6 +405,15 @@ pub struct ConfiguredPatterns { pub patterns: Vec, } +/// Configured boolean and the precedence layer that selected it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ConfiguredFlag { + /// Selected configuration layer. + pub source: ConfigSource, + /// Effective value. + pub value: bool, +} + /// Complete observable ignore policy for one registered repository. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct IgnorePolicyReport { @@ -408,6 +425,8 @@ pub struct IgnorePolicyReport { pub configured_excludes: ConfiguredPatterns, /// Exceptions to built-in default exclusions selected from configuration. pub include_defaults: ConfiguredPatterns, + /// Whether repository-contained `.gitignore` files participate in discovery. + pub use_gitignore: ConfiguredFlag, /// Rules ordered from lowest to highest precedence. pub effective_rules: Vec, } @@ -440,6 +459,52 @@ pub struct ConfigReport { pub repositories: Vec, } +/// Additive execution-policy view used by the CLI without expanding [`ExecutionPolicy`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ExecutionPolicyReport { + /// Patch-compatible base execution policy. + #[serde(flatten)] + pub base: ExecutionPolicy, + /// Effective `CodeGraph` corroboration bound. + #[serde(rename = "maxCodeGraphCorroborationAnchorsPerRepo")] + #[schemars(with = "i64")] + pub max_codegraph_corroboration_anchors_per_repo: CodeGraphCorroborationAnchorLimit, +} + +impl std::ops::Deref for ExecutionPolicyReport { + type Target = ExecutionPolicy; + + fn deref(&self) -> &Self::Target { + &self.base + } +} + +impl ExecutionPolicyReport { + /// Returns the canonical fingerprint of every effective policy value in this report. + #[must_use] + pub fn fingerprint(&self) -> String { + self.base + .fingerprint_with_codegraph_limit(self.max_codegraph_corroboration_anchors_per_repo) + } +} + +/// Extended configuration report emitted by `csgraph config show`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ExtendedConfigReport { + /// Output schema version. + pub schema_version: u8, + /// Workspace name from the manifest. + pub workspace: String, + /// Effective global extraction safety limits, including applied defaults. + pub extraction_budgets: ExtractionBudgets, + /// Effective global supervised-execution policy, including additive patch settings. + pub execution_policy: ExecutionPolicyReport, + /// Canonical fingerprint of the complete effective supervised-execution policy. + pub execution_policy_fingerprint: String, + /// Effective per-repository configuration in alias order. + pub repositories: Vec, +} + /// Current workspace registry and snapshot health. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct WorkspaceStatus { @@ -729,6 +794,7 @@ struct WorkspaceContext { repository_configs: BTreeMap, extraction_budgets: ExtractionBudgets, execution_policy: ExecutionPolicy, + codegraph_corroboration_anchor_limit: CodeGraphCorroborationAnchorLimit, } /// Resolves and reports native discovery exclusions without opening a graph database. @@ -742,6 +808,49 @@ pub fn show_config( selected_repository: Option<&str>, ) -> Result { let context = load_workspace_context(config_path, &ScanOverrides::default())?; + let repositories = config_report_repositories(&context, selected_repository)?; + Ok(ConfigReport { + schema_version: 1, + workspace: context.manifest.name, + extraction_budgets: context.extraction_budgets, + execution_policy_fingerprint: context.execution_policy.fingerprint(), + execution_policy: context.execution_policy, + repositories, + }) +} + +/// Resolves the extended configuration emitted by the CLI. +/// +/// # Errors +/// +/// Returns the same errors as [`show_config`]. +pub fn show_extended_config( + config_path: &Path, + selected_repository: Option<&str>, +) -> Result { + let context = load_workspace_context(config_path, &ScanOverrides::default())?; + let repositories = config_report_repositories(&context, selected_repository)?; + let fingerprint = context + .execution_policy + .fingerprint_with_codegraph_limit(context.codegraph_corroboration_anchor_limit); + Ok(ExtendedConfigReport { + schema_version: 1, + workspace: context.manifest.name, + extraction_budgets: context.extraction_budgets, + execution_policy: ExecutionPolicyReport { + base: context.execution_policy, + max_codegraph_corroboration_anchors_per_repo: context + .codegraph_corroboration_anchor_limit, + }, + execution_policy_fingerprint: fingerprint, + repositories, + }) +} + +fn config_report_repositories( + context: &WorkspaceContext, + selected_repository: Option<&str>, +) -> Result, ApplicationError> { if let Some(selected) = selected_repository && !context.manifest.repos.contains_key(selected) { @@ -749,7 +858,7 @@ pub fn show_config( selected.to_owned(), )); } - let repositories = context + context .manifest .repos .keys() @@ -769,15 +878,7 @@ pub fn show_config( ignore_policy: ignore_policy_report(&effective.ignore_policy), }) }) - .collect::, ApplicationError>>()?; - Ok(ConfigReport { - schema_version: 1, - workspace: context.manifest.name, - extraction_budgets: context.extraction_budgets, - execution_policy_fingerprint: context.execution_policy.fingerprint(), - execution_policy: context.execution_policy, - repositories, - }) + .collect() } fn ignore_policy_report(policy: &IgnorePolicy) -> IgnorePolicyReport { @@ -837,6 +938,10 @@ fn ignore_policy_report(policy: &IgnorePolicy) -> IgnorePolicyReport { source: policy.include_defaults_source(), patterns: policy.include_defaults().to_vec(), }, + use_gitignore: ConfiguredFlag { + source: policy.use_gitignore_source(), + value: policy.use_gitignore(), + }, effective_rules, } } @@ -3668,12 +3773,18 @@ fn load_workspace_context( overrides: &ScanOverrides, ) -> Result { let manifest_source = read_file(config_path)?; - let manifest = parse_manifest(&manifest_source)?; + let (manifest, manifest_extensions) = parse_manifest_with_extensions(&manifest_source)?; validate_global_policy_source(config_path, &manifest)?; let extraction_budgets = ExtractionBudgets::resolve(manifest.extraction_budgets.as_ref()) .map_err(ManifestError::from)?; let execution_policy = ExecutionPolicy::resolve(manifest.execution_policy.as_ref()) .map_err(ManifestError::from)?; + let codegraph_corroboration_anchor_limit = CodeGraphCorroborationAnchorLimit::try_from( + manifest_extensions + .max_codegraph_corroboration_anchors_per_repo() + .unwrap_or(DEFAULT_MAX_CODEGRAPH_CORROBORATION_ANCHORS_PER_REPO), + ) + .map_err(ManifestError::from)?; for alias in overrides.repo_openapi.keys() { if !manifest.repos.contains_key(alias) { return Err(ApplicationError::UnknownOverrideRepository(alias.clone())); @@ -3685,11 +3796,23 @@ fn load_workspace_context( semantic_manifest.execution_policy = None; let mut fingerprint_material = serde_json::to_string(&semantic_manifest) .map_err(|error| ApplicationError::Initialization(error.to_string()))?; + fingerprint_material.push('\n'); + fingerprint_material.push_str( + &serde_json::to_string(&manifest_extensions) + .map_err(|error| ApplicationError::Initialization(error.to_string()))?, + ); for (alias, repository) in &manifest.repos { let checkout_path = registry .checkout_path(alias) .ok_or_else(|| ApplicationError::RegistryAliasMissing(alias.clone()))?; - let mut effective = resolve_repository_config(checkout_path, repository)?; + let mut effective = match manifest_extensions.repository_use_gitignore(alias) { + Some(use_gitignore) => resolve_repository_config_with_use_gitignore( + checkout_path, + repository, + Some(use_gitignore), + )?, + None => resolve_repository_config(checkout_path, repository)?, + }; if let Some(openapi) = overrides.repo_openapi.get(alias) { apply_openapi_override(&mut effective, openapi)?; } @@ -3706,6 +3829,7 @@ fn load_workspace_context( repository_configs, extraction_budgets, execution_policy, + codegraph_corroboration_anchor_limit, }) } @@ -5156,13 +5280,12 @@ fn prepare_codegraph_jobs( )) }); anchors.dedup(); - if anchors.len() > 50 { - anchors.truncate(50); - setup_degradations.push(format!( - "CodeGraph symbol corroboration for `{}` was limited to 50 anchors", - repository.alias - )); - } + limit_codegraph_anchors( + &mut anchors, + context.codegraph_corroboration_anchor_limit, + &repository.alias, + &mut setup_degradations, + ); let mut changed_files = changed_files_by_repository .remove(&repository.id) .unwrap_or_default(); @@ -5187,6 +5310,24 @@ fn prepare_codegraph_jobs( (jobs, setup_degradations) } +fn limit_codegraph_anchors( + anchors: &mut Vec, + configured_limit: code_system_graph_core::CodeGraphCorroborationAnchorLimit, + repository: &str, + degradations: &mut Vec, +) { + let Some(limit) = configured_limit.bounded() else { + return; + }; + let limit = limit.get(); + if anchors.len() > limit { + anchors.truncate(limit); + degradations.push(format!( + "CodeGraph symbol corroboration for `{repository}` was limited to {limit} anchors" + )); + } +} + fn apply_codegraph_corroboration(graph: &mut GraphAssembly, reports: &[RepositoryCorroboration]) { for item in reports { for outcome in &item.report.symbols { @@ -5615,67 +5756,12 @@ fn discover_focused_artifacts( checkout_path: &Path, ignore_policy: &IgnorePolicy, ) -> Result, ApplicationError> { - let mut pending = vec![checkout_path.to_path_buf()]; - let canonical_checkout = - fs::canonicalize(checkout_path).map_err(|source| ApplicationError::ReadFile { - path: checkout_path.to_path_buf(), - source, - })?; - let mut visited = BTreeSet::new(); let mut discovered = Vec::new(); - while let Some(directory) = pending.pop() { - let canonical_directory = - fs::canonicalize(&directory).map_err(|source| ApplicationError::ReadFile { - path: directory.clone(), - source, - })?; - if !canonical_directory.starts_with(&canonical_checkout) { - return Err(ApplicationError::ArtifactOutsideCheckout { - path: canonical_directory, - checkout: canonical_checkout, - }); - } - if !visited.insert(canonical_directory) { - continue; - } + for relative in discover_repository_files(checkout_path, ignore_policy, None)? { worker::report_progress(code_system_graph_core::JobPhase::Discovery, 1); - let entries = fs::read_dir(&directory).map_err(|source| ApplicationError::ReadFile { - path: directory.clone(), - source, - })?; - for entry in entries { - let entry = entry.map_err(|source| ApplicationError::ReadFile { - path: directory.clone(), - source, - })?; - let file_type = entry - .file_type() - .map_err(|source| ApplicationError::ReadFile { - path: entry.path(), - source, - })?; - if file_type.is_symlink() { - continue; - } - let path = entry.path(); - let relative = path.strip_prefix(checkout_path).map_err(|_| { - ApplicationError::ArtifactOutsideCheckout { - path: path.clone(), - checkout: checkout_path.to_path_buf(), - } - })?; - if file_type.is_dir() { - if !ignore_policy.excludes(relative, true) { - pending.push(path); - } - continue; - } - if !file_type.is_file() || ignore_policy.excludes(relative, false) { - continue; - } - for extractor in focused_extractors_for_path(&path) { - discovered.push((relative.to_path_buf(), extractor)); - } + let path = checkout_path.join(&relative); + for extractor in focused_extractors_for_path(&path) { + discovered.push((relative.clone(), extractor)); } } discovered.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(right.1))); @@ -6125,6 +6211,56 @@ mod budget_regression_tests { } } +#[cfg(test)] +mod codegraph_job_tests { + use super::{SymbolAnchor, limit_codegraph_anchors}; + + fn anchors(count: usize) -> Vec { + (0..count) + .map(|index| SymbolAnchor { + symbol: format!("symbol_{index}"), + source_path: "src/lib.rs".to_owned(), + start_line: index + 1, + }) + .collect() + } + + #[test] + fn configured_anchor_limit_should_truncate_and_report_effective_value() { + let mut anchors = anchors(4); + let mut degradations = Vec::new(); + + limit_codegraph_anchors( + &mut anchors, + 2.try_into().expect("bounded test limit"), + "studio", + &mut degradations, + ); + + assert_eq!(anchors.len(), 2); + assert_eq!( + degradations, + ["CodeGraph symbol corroboration for `studio` was limited to 2 anchors"] + ); + } + + #[test] + fn unlimited_anchor_limit_should_preserve_every_anchor_without_degradation() { + let mut anchors = anchors(75); + let mut degradations = Vec::new(); + + limit_codegraph_anchors( + &mut anchors, + (-1).try_into().expect("unlimited test limit"), + "studio", + &mut degradations, + ); + + assert_eq!(anchors.len(), 75); + assert_eq!(degradations, Vec::::new()); + } +} + #[cfg(test)] mod codex_review_regression_tests { use code_system_graph_model::validate_safe_path_display; diff --git a/crates/code-system-graph-cli/src/main.rs b/crates/code-system-graph-cli/src/main.rs index da696f0..087b7c7 100644 --- a/crates/code-system-graph-cli/src/main.rs +++ b/crates/code-system-graph-cli/src/main.rs @@ -13,7 +13,7 @@ use clap::{ArgAction, CommandFactory, Parser, Subcommand, ValueEnum}; use code_system_graph::http_server::{BearerToken, HttpServerConfig, serve_http}; use code_system_graph::mcp::CodeSystemGraphServer; use code_system_graph::{ - ApplicationError, ChangesInput, CommunityInput, PullRequestInput, PullRequestListInput, ScanOverrides, SearchInput, TraceInput, add_repository_to_manifest, add_workspace_to_registry, analyze_workspace_changes_with_cancellation, application_exit_code, backup_database, communities_workspace, contracts_workspace, create_diagnostic_bundle, doctor_workspace, export_workspace, impact_workspace, impact_workspace_with_codegraph, initialize_workspace, inspect_pull_request_with_cancellation, list_pull_requests, list_repository_registry, list_workspace_registry, remove_repository_from_manifest, remove_workspace_from_registry, restore_database, run_worker_from_stdio, scan_workspace_with_overrides, search_workspace, show_config, status_workspace, sync_workspace_with_overrides, trace_workspace, traverse_workspace + AgentPluginCreateRequest, AgentPluginCreateTarget, AgentPluginError, AgentPluginUninstallRequest, ApplicationError, ChangesInput, CommunityInput, PullRequestInput, PullRequestListInput, ScanOverrides, SearchInput, TraceInput, add_repository_to_manifest, add_workspace_to_registry, agent_plugin_exit_code, analyze_workspace_changes_with_cancellation, application_exit_code, backup_database, communities_workspace, contracts_workspace, create_agent_plugin, create_diagnostic_bundle, doctor_workspace, export_workspace, impact_workspace, impact_workspace_with_codegraph, initialize_workspace, inspect_pull_request_with_cancellation, list_pull_requests, list_repository_registry, list_workspace_registry, load_agent_plugin_mcp_binding, remove_repository_from_manifest, remove_workspace_from_registry, restore_database, run_worker_from_stdio, scan_workspace_with_overrides, search_workspace, show_extended_config, status_workspace, sync_workspace_with_overrides, trace_workspace, traverse_workspace, uninstall_composed_integration }; use code_system_graph_core::{ ChangeAnalysisOptions, ChangeScope, ContractAction, ContractRequest, ExitCode, ExportFormat, ExportRequest, ImpactDirection, ImpactOptions, ImpactRequest, ImpactTarget, PullRequestListState, PullRequestOrderSuggestion, PullRequestOverlap, PullRequestProviderKind, PullRequestSemanticInput, TraversalAlgorithm, TraversalDirection, TraversalFilters, TraversalOptions, TraversalRequest, semantic_pull_request_overlap, suggest_pull_request_order @@ -168,6 +168,12 @@ enum Command { #[command(subcommand)] action: ConfigCommand, }, + /// Generate a portable plugin or bind an existing plugin to this workspace. + Plugin { + /// Plugin generation operation. + #[command(subcommand)] + command: PluginCommand, + }, /// Create a validated online database backup. Backup { /// Source `SQLite` database path. @@ -502,11 +508,14 @@ enum Command { /// Run the read-only MCP server over stdio. Mcp { /// `SQLite` database path. - #[arg(long)] - database: PathBuf, + #[arg(long, required_unless_present = "binding", conflicts_with = "binding")] + database: Option, /// Registered workspace name. - #[arg(long)] - workspace: String, + #[arg(long, required_unless_present = "binding", conflicts_with = "binding")] + workspace: Option, + /// Local binding generated by `plugin create`. + #[arg(long, conflicts_with_all = ["database", "workspace", "codegraph", "codegraph_binary"])] + binding: Option, /// Enable GitHub pull-request inspection; each request still requires consent. #[arg(long)] enable_github_pull_requests: bool, @@ -517,10 +526,10 @@ enum Command { #[arg(long)] admin: bool, /// Enable automatic bounded `CodeGraph` enrichment and scan corroboration. - #[arg(long)] + #[arg(long, conflicts_with = "binding")] codegraph: bool, /// Explicit `CodeGraph` executable path. - #[arg(long, requires = "codegraph")] + #[arg(long, requires = "codegraph", conflicts_with = "binding")] codegraph_binary: Option, }, } @@ -534,6 +543,7 @@ const fn command_name(command: &Command) -> &'static str { Command::Sync { .. } => "sync", Command::Status { .. } => "status", Command::Config { .. } => "config", + Command::Plugin { .. } => "plugin", Command::Backup { .. } => "backup", Command::Restore { .. } => "restore", Command::Workspace { .. } => "workspace", @@ -965,6 +975,49 @@ enum ConfigCommand { }, } +#[derive(Debug, Subcommand)] +enum PluginCommand { + /// Create a complete plugin or bind an existing portable plugin. + Create { + /// New, already-identical, or existing portable plugin directory. + #[arg(long)] + output: PathBuf, + /// Existing MCP server entry; enables existing-plugin mode with `routing-skill`. + #[arg(long, requires = "routing_skill")] + mcp_server_name: Option, + /// Existing routing skill; enables existing-plugin mode with `mcp-server-name`. + #[arg(long, requires = "mcp_server_name")] + routing_skill: Option, + /// Workspace manifest path. + #[arg(long, default_value = "code-system-graph.yaml")] + config: PathBuf, + /// Existing graph database path. + #[arg(long, default_value = ".code-system-graph/code-system-graph.db")] + database: PathBuf, + /// Enable read-only CodeGraph-backed MCP tools. + #[arg(long)] + codegraph: bool, + /// Explicit `CodeGraph` executable stored only in the ignored local binding. + #[arg(long, requires = "codegraph")] + codegraph_binary: Option, + /// Replace an older owned local receipt and binding in existing-plugin mode. + #[arg(long, requires = "mcp_server_name")] + replace_generated: bool, + }, + /// Remove one integration previously managed by `plugin create`. + Uninstall { + /// Existing portable plugin directory. + #[arg(long)] + output: PathBuf, + /// Managed MCP server entry to remove. + #[arg(long)] + mcp_server_name: String, + /// Managed routing skill to remove. + #[arg(long)] + routing_skill: String, + }, +} + #[derive(Debug, Subcommand)] enum RepoCommand { /// List repositories registered in one workspace. @@ -1422,9 +1475,13 @@ async fn handle_impact( #[tokio::main] async fn main() { if let Err(error) = run().await { - let exit_code = error - .downcast_ref::() - .map_or(ExitCode::Internal, application_exit_code); + let exit_code = if let Some(application) = error.downcast_ref::() { + application_exit_code(application) + } else if let Some(plugin) = error.downcast_ref::() { + agent_plugin_exit_code(plugin) + } else { + ExitCode::Internal + }; eprintln!("{error:#}"); std::process::exit(i32::from(exit_code.value())); } @@ -1595,9 +1652,54 @@ async fn dispatch(cli: Cli) -> anyhow::Result<()> { Command::Config { action: ConfigCommand::Show { config, repo }, } => { - let report = show_config(&config, repo.as_deref())?; + let report = show_extended_config(&config, repo.as_deref())?; println!("{}", serde_json::to_string(&report)?); } + Command::Plugin { command } => match command { + PluginCommand::Create { + output, + mcp_server_name, + routing_skill, + config, + database, + codegraph, + codegraph_binary, + replace_generated, + } => { + let target = match (mcp_server_name, routing_skill) { + (Some(mcp_server_name), Some(routing_skill)) => { + AgentPluginCreateTarget::Existing { + output, + mcp_server_name, + routing_skill, + replace_generated, + } + } + (None, None) => AgentPluginCreateTarget::Complete { output }, + _ => unreachable!("clap requires plugin integration options together"), + }; + let report = create_agent_plugin(&AgentPluginCreateRequest { + config, + database, + target, + codegraph, + codegraph_binary, + })?; + println!("{}", serde_json::to_string(&report)?); + } + PluginCommand::Uninstall { + output, + mcp_server_name, + routing_skill, + } => { + let report = uninstall_composed_integration(&AgentPluginUninstallRequest { + output, + mcp_server_name, + routing_skill, + })?; + println!("{}", serde_json::to_string(&report)?); + } + }, Command::Backup { database, output } => { let summary = backup_database(&database, &output)?; println!("{}", serde_json::to_string(&summary)?); @@ -1926,6 +2028,7 @@ async fn dispatch(cli: Cli) -> anyhow::Result<()> { Command::Mcp { database, workspace, + binding, enable_github_pull_requests, enable_bitbucket_pull_requests, admin, @@ -1935,6 +2038,23 @@ async fn dispatch(cli: Cli) -> anyhow::Result<()> { let admin = admin || std::env::var("CODE_SYSTEM_GRAPH_MCP_ADMIN") .is_ok_and(|value| value.trim() == "1"); + let (database, workspace, codegraph, codegraph_binary) = if let Some(binding) = binding + { + let binding = load_agent_plugin_mcp_binding(&binding)?; + ( + PathBuf::from(binding.database), + binding.workspace, + binding.codegraph_enabled, + binding.codegraph_binary.map(PathBuf::from), + ) + } else { + ( + database.expect("clap requires database without binding"), + workspace.expect("clap requires workspace without binding"), + codegraph, + codegraph_binary, + ) + }; let (codegraph, codegraph_binary) = codegraph_server_policy(codegraph, codegraph_binary); let service = CodeSystemGraphServer::new(database, workspace) diff --git a/crates/code-system-graph-cli/src/sync.rs b/crates/code-system-graph-cli/src/sync.rs index 6394cc9..31c1403 100644 --- a/crates/code-system-graph-cli/src/sync.rs +++ b/crates/code-system-graph-cli/src/sync.rs @@ -40,9 +40,17 @@ struct PersistedWatchTarget { configured_excludes_source: ConfigSource, include_defaults: Vec, include_defaults_source: ConfigSource, + #[serde(default)] + use_gitignore: bool, + #[serde(default = "default_config_source")] + use_gitignore_source: ConfigSource, explicit_paths: Vec, } +const fn default_config_source() -> ConfigSource { + ConfigSource::Default +} + impl From<&SyncTarget> for PersistedWatchTarget { fn from(target: &SyncTarget) -> Self { Self { @@ -52,6 +60,8 @@ impl From<&SyncTarget> for PersistedWatchTarget { configured_excludes_source: target.ignore_policy.configured_excludes_source(), include_defaults: target.ignore_policy.include_defaults().to_vec(), include_defaults_source: target.ignore_policy.include_defaults_source(), + use_gitignore: target.ignore_policy.use_gitignore(), + use_gitignore_source: target.ignore_policy.use_gitignore_source(), explicit_paths: target.explicit_paths.clone(), } } @@ -61,11 +71,13 @@ impl TryFrom for SyncTarget { type Error = ApplicationError; fn try_from(target: PersistedWatchTarget) -> Result { - let ignore_policy = IgnorePolicy::new( + let ignore_policy = IgnorePolicy::with_gitignore( target.configured_excludes, target.configured_excludes_source, target.include_defaults, target.include_defaults_source, + target.use_gitignore, + target.use_gitignore_source, ) .map_err(|error| { ApplicationError::Initialization(format!("invalid persisted watch scope: {error}")) @@ -716,7 +728,7 @@ mod tests { ); assert!(!report.enabled); assert_eq!(report.repository_count, 1); - assert!(report.repositories.is_empty()); + assert_eq!(report.repositories.as_slice(), &[]); } #[test] diff --git a/crates/code-system-graph-cli/src/sync_watch.rs b/crates/code-system-graph-cli/src/sync_watch.rs index a5f7294..581686c 100644 --- a/crates/code-system-graph-cli/src/sync_watch.rs +++ b/crates/code-system-graph-cli/src/sync_watch.rs @@ -15,6 +15,7 @@ use anyhow::Context; use code_system_graph::{ ApplicationError, IgnorePolicy, ScanOverrides, SyncSummary, finish_watcher_lease, heartbeat_watcher_lease, load_persisted_watch_targets, start_watcher_lease, sync_workspace_with_wall_time_cap }; +use code_system_graph_core::RepositoryPathMatcher; use notify::{Config, Event, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher}; use sysinfo::{Pid, ProcessesToUpdate, System}; use tokio::sync::mpsc; @@ -56,14 +57,21 @@ const WATCH_EVENT_PROTOCOL_MAX_BYTES: usize = 128; #[derive(Debug, serde::Deserialize, serde::Serialize)] #[serde(tag = "type", rename_all = "snake_case")] enum WatchEventMessage { - Ready { schema_version: u8 }, - Dirty { schema_version: u8 }, + Ready { + schema_version: u8, + }, + Dirty { + schema_version: u8, + #[serde(default)] + refresh_scope: bool, + }, } #[derive(Debug, Default)] struct WatchEventState { initial_ready: bool, refresh_started: Option, + refresh_scope: bool, failed: bool, } @@ -211,6 +219,14 @@ impl WatchEventWorker { fn receiver(&mut self) -> &mut mpsc::Receiver<()> { &mut self.receiver } + + fn take_scope_refresh(&self) -> anyhow::Result { + let mut state = self + .state + .lock() + .map_err(|_| anyhow::anyhow!("watcher protocol state was poisoned"))?; + Ok(std::mem::take(&mut state.refresh_scope)) + } } impl Drop for WatchEventWorker { @@ -249,8 +265,12 @@ fn spawn_watch_event_reader( current.initial_ready = true; current.refresh_started = None; } - WatchEventMessage::Dirty { schema_version: 1 } => { + WatchEventMessage::Dirty { + schema_version: 1, + refresh_scope, + } => { current.refresh_started.get_or_insert_with(Instant::now); + current.refresh_scope |= refresh_scope; drop(current); match sender.try_send(()) { Ok(()) | Err(mpsc::error::TrySendError::Full(())) => {} @@ -290,43 +310,86 @@ struct WatchScope { repositories: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] struct WatchRepository { root: PathBuf, ignore_policy: IgnorePolicy, + ignore_matcher: Arc>, explicit_paths: Vec, } impl WatchRepository { - fn relevant(&self, path: &Path) -> bool { - path.strip_prefix(&self.root).is_ok_and(|relative| { - self.explicit_paths.iter().any(|explicit| { + fn new(root: PathBuf, ignore_policy: IgnorePolicy, explicit_paths: Vec) -> Self { + let ignore_matcher = RepositoryPathMatcher::new(&root, ignore_policy.clone()); + Self { + root, + ignore_policy, + ignore_matcher: Arc::new(Mutex::new(ignore_matcher)), + explicit_paths, + } + } + + fn excluded(&self, relative: &Path, directory: bool) -> anyhow::Result { + let mut matcher = self + .ignore_matcher + .lock() + .map_err(|_| anyhow::anyhow!("repository ignore matcher was poisoned"))?; + matcher + .excludes(relative, directory) + .map_err(anyhow::Error::new) + } + + fn relevant(&self, path: &Path) -> anyhow::Result { + let Ok(relative) = path.strip_prefix(&self.root) else { + return Ok(false); + }; + if (self.ignore_policy.use_gitignore() + && relative + .file_name() + .is_some_and(|name| name == ".gitignore")) + || self.explicit_paths.iter().any(|explicit| { relative == explicit || explicit.starts_with(relative) || relative.starts_with(explicit) - }) || !self.ignore_policy.excludes(relative, path.is_dir()) - }) + }) + { + return Ok(true); + } + Ok(!self.excluded(relative, path.is_dir())?) } #[cfg(any(target_os = "linux", test))] - fn should_watch_directory(&self, path: &Path) -> bool { - path.strip_prefix(&self.root).is_ok_and(|relative| { - self.explicit_paths - .iter() - .any(|explicit| explicit.starts_with(relative)) - || !self.ignore_policy.excludes(relative, true) - }) + fn should_watch_directory(&self, path: &Path) -> anyhow::Result { + let Ok(relative) = path.strip_prefix(&self.root) else { + return Ok(false); + }; + if self + .explicit_paths + .iter() + .any(|explicit| explicit.starts_with(relative)) + { + return Ok(true); + } + Ok(!self.excluded(relative, true)?) } } +impl PartialEq for WatchRepository { + fn eq(&self, other: &Self) -> bool { + self.root == other.root + && self.ignore_policy == other.ignore_policy + && self.explicit_paths == other.explicit_paths + } +} + +impl Eq for WatchRepository {} + impl WatchScope { fn load(config: &Path, database: &Path, workspace: &str) -> anyhow::Result { let mut repositories = load_persisted_watch_targets(database, workspace)? .into_iter() - .map(|target| WatchRepository { - root: target.path, - ignore_policy: target.ignore_policy, - explicit_paths: target.explicit_paths, + .map(|target| { + WatchRepository::new(target.path, target.ignore_policy, target.explicit_paths) }) .collect::>(); repositories.sort_by(|left, right| left.root.cmp(&right.root)); @@ -337,27 +400,51 @@ impl WatchScope { }) } - fn relevant_event(&self, event: &Event) -> bool { - event.paths.is_empty() || event.paths.iter().any(|path| self.relevant_path(path)) + fn relevant_event(&self, event: &Event) -> anyhow::Result { + if event.paths.is_empty() { + return Ok(true); + } + for path in &event.paths { + if self.relevant_path(path)? { + return Ok(true); + } + } + Ok(false) + } + + fn changes_enabled_gitignore(&self, event: &Event) -> bool { + event.paths.iter().any(|path| { + path.file_name().is_some_and(|name| name == ".gitignore") + && self.repositories.iter().any(|repository| { + repository.ignore_policy.use_gitignore() + && path.strip_prefix(&repository.root).is_ok() + }) + }) } - fn relevant_path(&self, path: &Path) -> bool { + fn relevant_path(&self, path: &Path) -> anyhow::Result { if path == self.config { - return true; + return Ok(true); } if database_artifact(path, &self.database) { - return false; + return Ok(false); } - self.repositories - .iter() - .any(|repository| repository.relevant(path)) + for repository in &self.repositories { + if repository.relevant(path)? { + return Ok(true); + } + } + Ok(false) } #[cfg(any(target_os = "linux", test))] - fn should_watch_directory(&self, path: &Path) -> bool { - self.repositories - .iter() - .any(|repository| repository.should_watch_directory(path)) + fn should_watch_directory(&self, path: &Path) -> anyhow::Result { + for repository in &self.repositories { + if repository.should_watch_directory(path)? { + return Ok(true); + } + } + Ok(false) } fn watch_entries(&self) -> Vec<(PathBuf, RecursiveMode)> { @@ -537,17 +624,22 @@ pub(crate) async fn run_watch_event_worker( let scope = WatchScope::load(&config, &database, &workspace)?; let (sender, mut receiver) = mpsc::channel(1); let refresh_required = Arc::new(AtomicBool::new(false)); + let scope_refresh_required = Arc::new(AtomicBool::new(false)); let requested_poll = poll_interval_ms.map(Duration::from_millis); let mut watcher = build_watcher( &scope, sender.clone(), Arc::clone(&refresh_required), + Arc::clone(&scope_refresh_required), requested_poll, &policy, )?; emit_watch_event(&WatchEventMessage::Ready { schema_version: 1 })?; while let Some(signal) = receiver.recv().await { - emit_watch_event(&WatchEventMessage::Dirty { schema_version: 1 })?; + emit_watch_event(&WatchEventMessage::Dirty { + schema_version: 1, + refresh_scope: scope_refresh_required.swap(false, Ordering::AcqRel), + })?; let refresh = refresh_required.swap(false, Ordering::AcqRel) || matches!(signal, WatchSignal::RefreshDirectories); if refresh { @@ -559,6 +651,7 @@ pub(crate) async fn run_watch_event_worker( &scope, sender.clone(), Arc::clone(&refresh_required), + Arc::clone(&scope_refresh_required), requested_poll.unwrap_or(DEFAULT_POLL_INTERVAL), &policy, )?; @@ -728,6 +821,13 @@ pub(crate) async fn watch_workspace( return Ok(()); } } + let refresh_scope = match watcher.take_scope_refresh() { + Ok(refresh_scope) => refresh_scope, + Err(error) => { + finish_after_error(&database, &workspace, &owner_token, &error)?; + return Err(watcher_public_error(&error)); + } + }; let minimum_start = last_pass_started + Duration::from_millis(policy.min_watch_rescan_interval_ms); if Instant::now() < minimum_start { @@ -784,7 +884,7 @@ pub(crate) async fn watch_workspace( continue; } }; - if refreshed != scope { + if refresh_scope || refreshed != scope { let replacement = match WatchEventWorker::spawn( &config, &database, @@ -1045,6 +1145,7 @@ fn build_watcher( scope: &WatchScope, sender: mpsc::Sender, refresh_required: Arc, + scope_refresh_required: Arc, requested_poll_interval: Option, policy: &code_system_graph_core::ExecutionPolicy, ) -> anyhow::Result { @@ -1059,12 +1160,19 @@ fn build_watcher( scope, sender, refresh_required, + scope_refresh_required, requested_poll_interval.unwrap_or(DEFAULT_POLL_INTERVAL), policy, ); } - match build_native_watcher(scope, sender.clone(), Arc::clone(&refresh_required), policy) { + match build_native_watcher( + scope, + sender.clone(), + Arc::clone(&refresh_required), + Arc::clone(&scope_refresh_required), + policy, + ) { Ok(watcher) => Ok(watcher), Err(error) if watcher_limit_exceeded(&error) => Err(error), Err(error) => { @@ -1073,6 +1181,7 @@ fn build_watcher( scope, sender, refresh_required, + scope_refresh_required, DEFAULT_POLL_INTERVAL, policy, ) @@ -1090,11 +1199,20 @@ fn build_native_watcher( scope: &WatchScope, sender: mpsc::Sender, refresh_required: Arc, + scope_refresh_required: Arc, policy: &code_system_graph_core::ExecutionPolicy, ) -> anyhow::Result { let callback_scope = scope.clone(); let mut watcher = RecommendedWatcher::new( - move |result| forward_event(result, &callback_scope, &sender, &refresh_required), + move |result| { + forward_event( + result, + &callback_scope, + &sender, + &refresh_required, + &scope_refresh_required, + ); + }, Config::default().with_follow_symlinks(false), ) .context("failed to create native filesystem watcher")?; @@ -1116,6 +1234,7 @@ fn build_poll_watcher( scope: &WatchScope, sender: mpsc::Sender, refresh_required: Arc, + scope_refresh_required: Arc, interval: Duration, policy: &code_system_graph_core::ExecutionPolicy, ) -> anyhow::Result { @@ -1125,7 +1244,15 @@ fn build_poll_watcher( .with_compare_contents(true) .with_follow_symlinks(false); let mut watcher = PollWatcher::new( - move |result| forward_event(result, &callback_scope, &sender, &refresh_required), + move |result| { + forward_event( + result, + &callback_scope, + &sender, + &refresh_required, + &scope_refresh_required, + ); + }, config, ) .context("failed to create polling filesystem watcher")?; @@ -1186,7 +1313,7 @@ fn add_native_watch_entries( let file_type = entry.file_type()?; if file_type.is_dir() && !file_type.is_symlink() - && scope.should_watch_directory(&entry.path()) + && scope.should_watch_directory(&entry.path())? { pending.push(entry.path()); } @@ -1210,18 +1337,29 @@ fn forward_event( scope: &WatchScope, sender: &mpsc::Sender, refresh_required: &AtomicBool, + scope_refresh_required: &AtomicBool, ) { match result { - Ok(event) if scope.relevant_event(&event) => { - let signal = if event.kind.is_create() && event.paths.iter().any(|path| path.is_dir()) { - refresh_required.store(true, Ordering::Release); - WatchSignal::RefreshDirectories - } else { - WatchSignal::Dirty - }; - let _ignored = sender.try_send(signal); - } - Ok(_) => {} + Ok(event) => match scope.relevant_event(&event) { + Ok(true) => { + if scope.changes_enabled_gitignore(&event) { + scope_refresh_required.store(true, Ordering::Release); + } + let signal = + if event.kind.is_create() && event.paths.iter().any(|path| path.is_dir()) { + refresh_required.store(true, Ordering::Release); + WatchSignal::RefreshDirectories + } else { + WatchSignal::Dirty + }; + let _ignored = sender.try_send(signal); + } + Ok(false) => {} + Err(error) => { + eprintln!("csgraph sync could not apply repository ignore policy: {error}"); + let _ignored = sender.try_send(WatchSignal::Dirty); + } + }, Err(error) if !error.paths.is_empty() && error @@ -1340,6 +1478,7 @@ mod tests { let state = Arc::new(Mutex::new(WatchEventState { initial_ready: true, refresh_started: Some(Instant::now()), + refresh_scope: false, failed: false, })); let input = concat!( @@ -1376,6 +1515,7 @@ mod tests { let state = Arc::new(Mutex::new(WatchEventState { initial_ready: true, refresh_started: Some(original), + refresh_scope: false, failed: false, })); let input = concat!( @@ -1415,12 +1555,56 @@ mod tests { let event = Event::new(notify::EventKind::Create(notify::event::CreateKind::Folder)) .add_path(directory); - forward_event(Ok(event), &scope, &sender, &refresh_required); + let scope_refresh_required = AtomicBool::new(false); + forward_event( + Ok(event), + &scope, + &sender, + &refresh_required, + &scope_refresh_required, + ); assert!(matches!(receiver.try_recv(), Ok(WatchSignal::Dirty))); assert!(refresh_required.swap(false, Ordering::AcqRel)); } + #[test] + fn gitignore_change_should_request_scope_rebuild_even_when_channel_is_full() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let root = temporary.path().join("repo"); + std::fs::create_dir(&root).expect("repository root"); + let scope = WatchScope { + config: temporary.path().join("code-system-graph.yaml"), + database: temporary.path().join("graph.db"), + repositories: vec![WatchRepository::new( + root.clone(), + gitignore_policy(), + Vec::new(), + )], + }; + let (sender, mut receiver) = mpsc::channel(1); + sender + .try_send(WatchSignal::Dirty) + .expect("channel should accept prefill"); + let refresh_required = AtomicBool::new(false); + let scope_refresh_required = AtomicBool::new(false); + let event = Event::new(notify::EventKind::Modify(notify::event::ModifyKind::Data( + notify::event::DataChange::Any, + ))) + .add_path(root.join(".gitignore")); + + forward_event( + Ok(event), + &scope, + &sender, + &refresh_required, + &scope_refresh_required, + ); + + assert!(matches!(receiver.try_recv(), Ok(WatchSignal::Dirty))); + assert!(scope_refresh_required.swap(false, Ordering::AcqRel)); + } + #[test] fn watcher_error_detail_should_not_expose_worker_parser_messages() { let secret = "private-literal-7f2381"; @@ -1464,46 +1648,98 @@ mod tests { .expect("built-in ignore policy") } + fn gitignore_policy() -> IgnorePolicy { + IgnorePolicy::with_gitignore( + Vec::new(), + code_system_graph::ConfigSource::WorkspaceManifest, + Vec::new(), + code_system_graph::ConfigSource::WorkspaceManifest, + true, + code_system_graph::ConfigSource::WorkspaceManifest, + ) + .expect("Git ignore policy") + } + + #[test] + fn scope_should_apply_gitignore_and_observe_rule_changes() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let root = temporary.path().join("repo"); + std::fs::create_dir_all(root.join("nested"))?; + std::fs::write(root.join(".gitignore"), "ignored.rs\n")?; + std::fs::write(root.join("nested/.gitignore"), "*.rs\n!keep.rs\n")?; + let scope = WatchScope { + config: temporary.path().join("code-system-graph.yaml"), + database: temporary.path().join("graph.db"), + repositories: vec![WatchRepository::new( + root.clone(), + gitignore_policy(), + Vec::new(), + )], + }; + + assert!(!scope.relevant_path(&root.join("ignored.rs"))?); + assert!(!scope.relevant_path(&root.join("nested/drop.rs"))?); + assert!(scope.relevant_path(&root.join("nested/keep.rs"))?); + assert!(scope.relevant_path(&root.join("nested/.gitignore"))?); + + std::fs::write(root.join(".gitignore"), "")?; + std::fs::write(root.join("nested/.gitignore"), "")?; + let refreshed = WatchScope { + config: temporary.path().join("code-system-graph.yaml"), + database: temporary.path().join("graph.db"), + repositories: vec![WatchRepository::new( + root.clone(), + gitignore_policy(), + Vec::new(), + )], + }; + assert!(refreshed.relevant_path(&root.join("ignored.rs"))?); + assert!(refreshed.relevant_path(&root.join("nested/drop.rs"))?); + Ok(()) + } + #[test] - fn scope_should_ignore_generated_state_and_database_sidecars() { + fn scope_should_ignore_generated_state_and_database_sidecars() -> anyhow::Result<()> { let scope = WatchScope { config: PathBuf::from("/workspace/code-system-graph.yaml"), database: PathBuf::from("/workspace/.state/graph.db"), - repositories: vec![WatchRepository { - root: PathBuf::from("/workspace/repo"), - ignore_policy: policy(&[], &[]), - explicit_paths: vec![PathBuf::from(".code-system-graph.yaml")], - }], + repositories: vec![WatchRepository::new( + PathBuf::from("/workspace/repo"), + policy(&[], &[]), + vec![PathBuf::from(".code-system-graph.yaml")], + )], }; - assert!(scope.relevant_path(Path::new("/workspace/repo/src/lib.rs"))); - assert!(scope.relevant_path(Path::new("/workspace/code-system-graph.yaml"))); - assert!(!scope.relevant_path(Path::new("/workspace/repo/.codegraph/codegraph.db-wal"))); - assert!(!scope.relevant_path(Path::new("/workspace/.state/graph.db-wal"))); - assert!(!scope.relevant_path(Path::new("/workspace/.state/graph.db.work-v1.db-wal"))); - assert!(!scope.relevant_path(Path::new("/workspace/.state/graph.db.work-v1.db-shm"))); + assert!(scope.relevant_path(Path::new("/workspace/repo/src/lib.rs"))?); + assert!(scope.relevant_path(Path::new("/workspace/code-system-graph.yaml"))?); + assert!(!scope.relevant_path(Path::new("/workspace/repo/.codegraph/codegraph.db-wal"))?); + assert!(!scope.relevant_path(Path::new("/workspace/.state/graph.db-wal"))?); + assert!(!scope.relevant_path(Path::new("/workspace/.state/graph.db.work-v1.db-wal"))?); + assert!(!scope.relevant_path(Path::new("/workspace/.state/graph.db.work-v1.db-shm"))?); + Ok(()) } #[test] - fn scope_should_apply_custom_excludes_and_default_includes() { + fn scope_should_apply_custom_excludes_and_default_includes() -> anyhow::Result<()> { let scope = WatchScope { config: PathBuf::from("/workspace/code-system-graph.yaml"), database: PathBuf::from("/workspace/.state/graph.db"), - repositories: vec![WatchRepository { - root: PathBuf::from("/workspace/repo"), - ignore_policy: policy(&["./generated//./**"], &["./vendor//internal-sdk/./**"]), - explicit_paths: vec![PathBuf::from("generated/explicit.yaml")], - }], + repositories: vec![WatchRepository::new( + PathBuf::from("/workspace/repo"), + policy(&["./generated//./**"], &["./vendor//internal-sdk/./**"]), + vec![PathBuf::from("generated/explicit.yaml")], + )], }; assert_eq!( ( - scope.relevant_path(Path::new("/workspace/repo/generated/output.rs")), - scope.relevant_path(Path::new("/workspace/repo/vendor/internal-sdk/src/lib.rs")), - scope.relevant_path(Path::new("/workspace/repo/vendor/external/lib.rs")), - scope.relevant_path(Path::new("/workspace/repo/generated/explicit.yaml")), + scope.relevant_path(Path::new("/workspace/repo/generated/output.rs"))?, + scope.relevant_path(Path::new("/workspace/repo/vendor/internal-sdk/src/lib.rs"))?, + scope.relevant_path(Path::new("/workspace/repo/vendor/external/lib.rs"))?, + scope.relevant_path(Path::new("/workspace/repo/generated/explicit.yaml"))?, ), (false, true, false, true) ); + Ok(()) } #[test] @@ -1518,22 +1754,14 @@ mod tests { config: temporary.path().join("code-system-graph.yaml"), database: temporary.path().join("graph.db"), repositories: vec![ - WatchRepository { - root: root.clone(), - ignore_policy: policy(&["generated/*"], &[]), - explicit_paths: Vec::new(), - }, - WatchRepository { - root, - ignore_policy: policy(&[], &[]), - explicit_paths: Vec::new(), - }, + WatchRepository::new(root.clone(), policy(&["generated/*"], &[]), Vec::new()), + WatchRepository::new(root, policy(&[], &[]), Vec::new()), ], }; assert_eq!(scope.repositories.len(), 2); - assert!(scope.relevant_path(&source)); - assert!(scope.should_watch_directory(&repository)); + assert!(scope.relevant_path(&source)?); + assert!(scope.should_watch_directory(&repository)?); Ok(()) } diff --git a/crates/code-system-graph-cli/src/work_state.rs b/crates/code-system-graph-cli/src/work_state.rs index e75ddb6..dcdf948 100644 --- a/crates/code-system-graph-cli/src/work_state.rs +++ b/crates/code-system-graph-cli/src/work_state.rs @@ -1170,11 +1170,12 @@ mod tests { .expect("load"), vec![batch.clone()] ); - assert!( + assert_eq!( state .load_batches(&[fingerprint("two")], "budget", "1.0.0", 1_000_000, 3) .expect("load") - .is_empty() + .as_slice(), + &[] ); assert!(!state.put_batch(&batch, 1, 4).expect("oversized skip")); } diff --git a/crates/code-system-graph-cli/src/worker.rs b/crates/code-system-graph-cli/src/worker.rs index 5e9eb56..bfc7c6b 100644 --- a/crates/code-system-graph-cli/src/worker.rs +++ b/crates/code-system-graph-cli/src/worker.rs @@ -1412,7 +1412,7 @@ mod tests { }; let restored = application_failure(failure); assert_eq!(application_exit_code(&restored), expected); - assert!(!restored.to_string().is_empty()); + assert_ne!(restored.to_string(), ""); } } diff --git a/crates/code-system-graph-cli/tests/agent_plugin_e2e.rs b/crates/code-system-graph-cli/tests/agent_plugin_e2e.rs new file mode 100644 index 0000000..b50f867 --- /dev/null +++ b/crates/code-system-graph-cli/tests/agent_plugin_e2e.rs @@ -0,0 +1,938 @@ +//! Portable generation, existing-plugin binding, and MCP-profile acceptance coverage. + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::process::{Command, Stdio}; + +use code_system_graph::{ + AgentPluginCreateMode, AgentPluginCreateReport, AgentPluginUninstallReport, scan_workspace +}; +use rmcp::ServiceExt; +use rmcp::model::{ClientCapabilities, ClientInfo, Implementation}; + +const PLUGIN_SCHEMA: &str = include_str!( + "../../code-system-graph-hooks/agent-integration-template/agent-plugin/schemas/1.0.0/plugin.schema.json" +); +const MCP_SCHEMA: &str = include_str!( + "../../code-system-graph-hooks/agent-integration-template/agent-plugin/schemas/1.0.0/mcp.schema.json" +); +const LOCAL_BINDING: &str = ".local/code-system-graph/mcp-binding.json"; + +fn canonical_string(path: &std::path::Path) -> anyhow::Result { + Ok(std::fs::canonicalize(path)?.to_string_lossy().into_owned()) +} + +fn assert_generator_build(metadata: &serde_json::Value) { + assert_eq!(metadata["version"], env!("CARGO_PKG_VERSION")); + let fingerprint = metadata["binaryFingerprint"] + .as_str() + .expect("binary fingerprint"); + assert!(fingerprint.starts_with("blake3:")); + assert_eq!(fingerprint.len(), "blake3:".len() + 64); + assert!(metadata["sourceCommit"].is_string() || metadata["sourceCommit"].is_null()); + assert!(metadata["sourceDirty"].is_boolean() || metadata["sourceDirty"].is_null()); +} + +fn fixture_workspace( + root: &std::path::Path, +) -> anyhow::Result<(std::path::PathBuf, std::path::PathBuf)> { + fixture_workspace_named(root, "plugin-workspace") +} + +fn fixture_workspace_named( + root: &std::path::Path, + workspace: &str, +) -> anyhow::Result<(std::path::PathBuf, std::path::PathBuf)> { + let repository = root.join("répo with spaces"); + std::fs::create_dir_all(repository.join("src"))?; + std::fs::write( + repository.join("Cargo.toml"), + "[package]\nname = \"plugin-fixture\"\nversion = \"1.0.0\"\nedition = \"2024\"\n", + )?; + std::fs::write(repository.join("src/lib.rs"), "pub fn boundary() {}\n")?; + let manifest = root.join("code system graph.yaml"); + std::fs::write( + &manifest, + format!( + "version: 1\nname: {}\nrepos:\n app:\n path: répo with spaces\n", + serde_json::to_string(workspace)? + ), + )?; + let database = root.join("graph data.db"); + scan_workspace(&manifest, &database)?; + Ok((manifest, database)) +} + +#[test] +fn plugin_create_should_quote_workspace_names_in_skill_frontmatter() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let (manifest, database) = fixture_workspace_named(temporary.path(), "plugin: workspace")?; + let output = temporary.path().join("portable-plugin"); + + let created = create_with_cli(&manifest, &database, &output, &[])?; + assert!( + created.status.success(), + "{}", + String::from_utf8_lossy(&created.stderr) + ); + let report: AgentPluginCreateReport = serde_json::from_slice(&created.stdout)?; + let skill = + std::fs::read_to_string(output.join(format!("skills/{}/SKILL.md", report.skill_name)))?; + let frontmatter = skill + .strip_prefix("---\n") + .and_then(|value| value.split_once("\n---\n")) + .map(|(frontmatter, _)| frontmatter) + .ok_or_else(|| anyhow::anyhow!("generated skill frontmatter is missing"))?; + let parsed: std::collections::BTreeMap = serde_saphyr::from_str(frontmatter)?; + + assert_eq!(parsed.get("name"), Some(&report.skill_name)); + assert!( + parsed + .get("description") + .is_some_and(|description| description.contains("`plugin: workspace`")) + ); + Ok(()) +} + +fn create_with_cli( + manifest: &std::path::Path, + database: &std::path::Path, + output: &std::path::Path, + extra: &[&str], +) -> anyhow::Result { + let mut command = Command::new(env!("CARGO_BIN_EXE_csgraph")); + command + .args(["plugin", "create", "--output"]) + .arg(output) + .arg("--config") + .arg(manifest) + .arg("--database") + .arg(database) + .args(extra); + Ok(command.output()?) +} + +fn fixture_base_plugin(root: &std::path::Path) -> anyhow::Result { + let base = root.join("hugint base plugin"); + std::fs::create_dir_all(base.join(".codex-plugin"))?; + std::fs::create_dir_all(base.join("skills"))?; + std::fs::write( + base.join("plugin.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "hugint-agent-plugin", + "version": "0.2.1", + "description": "Portable Hugint base" + }))?, + )?; + let codegraph_server = serde_json::json!({ + "type": "stdio", + "command": "codegraph", + "args": ["serve", "--mcp"] + }); + std::fs::write( + base.join("mcp.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "codegraph": codegraph_server + } + }))?, + )?; + std::fs::write( + base.join(".codex-plugin/plugin.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "name": "hugint-agent-plugin", + "version": "0.2.1+codex.local", + "skills": "./skills/", + "mcpServers": { + "codegraph": codegraph_server + } + }))?, + )?; + std::fs::write(base.join(".gitignore"), "/.local/\n")?; + std::fs::write(base.join("README.md"), "portable base\n")?; + Ok(base) +} + +fn bind_existing_with_cli( + base: &std::path::Path, + manifest: &std::path::Path, + database: &std::path::Path, + extra: &[&str], +) -> anyhow::Result { + let mut command = Command::new(env!("CARGO_BIN_EXE_csgraph")); + command + .args(["plugin", "create", "--output"]) + .arg(base) + .args(["--mcp-server-name", "hugint-code-system-graph"]) + .args(["--routing-skill", "hugint-system-graph"]) + .arg("--config") + .arg(manifest) + .arg("--database") + .arg(database) + .args(extra); + Ok(command.output()?) +} + +fn uninstall_existing_with_cli(base: &std::path::Path) -> anyhow::Result { + Ok(Command::new(env!("CARGO_BIN_EXE_csgraph")) + .args(["plugin", "uninstall", "--output"]) + .arg(base) + .args(["--mcp-server-name", "hugint-code-system-graph"]) + .args(["--routing-skill", "hugint-system-graph"]) + .output()?) +} + +fn expanded_mcp_arguments( + document: &serde_json::Value, + server_name: &str, + plugin_root: &std::path::Path, +) -> Vec { + document["mcpServers"][server_name]["args"] + .as_array() + .expect("MCP args") + .iter() + .map(|value| { + value + .as_str() + .expect("string arg") + .replace("${PLUGIN_ROOT}", &plugin_root.to_string_lossy()) + }) + .collect() +} + +fn assert_openai_skill_metadata( + path: &std::path::Path, + display_name: &str, + skill_name: &str, +) -> anyhow::Result<()> { + let metadata: serde_json::Value = serde_saphyr::from_str(&std::fs::read_to_string(path)?)?; + assert_eq!(metadata["interface"]["display_name"], display_name); + assert_eq!( + metadata["interface"]["short_description"], + "Workspace graph navigation and impact" + ); + assert!( + metadata["interface"]["default_prompt"] + .as_str() + .is_some_and(|prompt| prompt.contains(&format!("${skill_name}"))) + ); + Ok(()) +} + +#[cfg(unix)] +fn fake_codegraph(directory: &std::path::Path) -> anyhow::Result { + let binary = directory.join("codegraph fake"); + std::fs::copy( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/codegraph/fake/codegraph.py"), + &binary, + )?; + let mut permissions = std::fs::metadata(&binary)?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&binary, permissions)?; + Ok(binary) +} + +#[test] +fn plugin_create_should_render_official_structure_and_be_idempotent() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let (manifest, database) = fixture_workspace(temporary.path())?; + let output = temporary.path().join("portable plugin"); + + let first = create_with_cli(&manifest, &database, &output, &[])?; + assert!( + first.status.success(), + "{}", + String::from_utf8_lossy(&first.stderr) + ); + let first: AgentPluginCreateReport = serde_json::from_slice(&first.stdout)?; + assert!(first.changed); + assert_eq!(first.mode, AgentPluginCreateMode::CompletePlugin); + assert_eq!(first.workspace, "plugin-workspace"); + assert!( + first + .plugin_name + .starts_with("code-system-graph-plugin-workspace-") + ); + assert_eq!(first.plugin_name, first.mcp_server_name); + assert_eq!(first.plugin_name, first.skill_name); + assert_eq!(first.activation_scope, "client_managed_project_local"); + assert_eq!( + first.workspace_root, + std::fs::canonicalize(temporary.path())?.to_string_lossy() + ); + assert!(!first.codegraph_enabled); + assert_eq!( + first.binding, + canonical_string(&output.join(LOCAL_BINDING))? + ); + assert_eq!(first.files.len(), 7); + + let plugin: serde_json::Value = + serde_json::from_slice(&std::fs::read(output.join("plugin.json"))?)?; + let plugin_schema: serde_json::Value = serde_json::from_str(PLUGIN_SCHEMA)?; + assert!(jsonschema::validator_for(&plugin_schema)?.is_valid(&plugin)); + assert_eq!( + plugin["$schema"], + "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" + ); + assert_eq!(plugin["name"], first.plugin_name); + assert_eq!(plugin["version"], "1.0.3"); + let mcp: serde_json::Value = serde_json::from_slice(&std::fs::read(output.join("mcp.json"))?)?; + let mcp_schema: serde_json::Value = serde_json::from_str(MCP_SCHEMA)?; + assert!(jsonschema::validator_for(&mcp_schema)?.is_valid(&mcp)); + let server = &mcp["mcpServers"][&first.mcp_server_name]; + assert_eq!(server["command"], "csgraph"); + assert_eq!(server["type"], "stdio"); + assert_eq!( + server["args"], + serde_json::json!([ + "mcp", + "--binding", + "${PLUGIN_ROOT}/.local/code-system-graph/mcp-binding.json" + ]) + ); + let skill_path = output.join(format!("skills/{}/SKILL.md", first.skill_name)); + let skill = std::fs::read_to_string(skill_path)?; + assert!(skill.starts_with(&format!("---\nname: \"{}\"\n", first.skill_name))); + assert!(!skill.contains("\ncompatibility:")); + assert!(!skill.contains("\nmetadata:")); + assert!(!skill.contains("{{")); + assert!(!skill.contains(&temporary.path().to_string_lossy().to_string())); + assert!(!skill.contains("workspace.md")); + assert!(!skill.contains("plugin create")); + assert!(!skill.contains("local binding")); + assert!(skill.contains("reports another workspace, stop using it")); + assert!(skill.contains("Call `status` before")); + assert!(skill.contains("Do not run `scan`, `sync`, `codegraph init`")); + assert!( + !output + .join(format!("skills/{}/agents/openai.yaml", first.skill_name)) + .exists() + ); + let operating_guide = std::fs::read_to_string(output.join(format!( + "skills/{}/references/operating-guide.md", + first.skill_name + )))?; + assert!(operating_guide.contains("Repository onboarding")); + assert!(operating_guide.contains("Inspect the installed `csgraph` and `codegraph` versions")); + assert!(!operating_guide.contains("This plugin")); + assert!(!operating_guide.contains("workspace.md")); + assert!(!operating_guide.contains("{{")); + let gitignore = std::fs::read_to_string(output.join(".gitignore"))?; + assert_eq!(gitignore, "/.local/\n"); + let binding: serde_json::Value = serde_json::from_slice(&std::fs::read( + output.join(".local/code-system-graph/mcp-binding.json"), + )?)?; + assert_eq!(binding["generator"], "csgraph plugin binding"); + assert_generator_build(&binding["generatorBuild"]); + assert_eq!(binding["workspace"], "plugin-workspace"); + assert_eq!(binding["config"], canonical_string(&manifest)?); + assert_eq!(binding["database"], canonical_string(&database)?); + assert!(!binding["codegraphEnabled"].as_bool().expect("boolean")); + assert!(std::fs::read_to_string(output.join("LICENSE"))?.contains("Apache License")); + + let second = create_with_cli(&manifest, &database, &output, &[])?; + assert!(second.status.success()); + let second: AgentPluginCreateReport = serde_json::from_slice(&second.stdout)?; + assert!(!second.changed); + Ok(()) +} + +#[test] +fn plugin_create_should_use_the_same_portable_identity_for_workspace_clones() -> anyhow::Result<()> +{ + let temporary = tempfile::tempdir()?; + let first_root = temporary.path().join("first"); + let second_root = temporary.path().join("second"); + std::fs::create_dir_all(&first_root)?; + std::fs::create_dir_all(&second_root)?; + let (first_manifest, first_database) = fixture_workspace(&first_root)?; + let (second_manifest, second_database) = fixture_workspace(&second_root)?; + + let first = create_with_cli( + &first_manifest, + &first_database, + &first_root.join("plugin"), + &[], + )?; + let second = create_with_cli( + &second_manifest, + &second_database, + &second_root.join("plugin"), + &[], + )?; + let first: AgentPluginCreateReport = serde_json::from_slice(&first.stdout)?; + let second: AgentPluginCreateReport = serde_json::from_slice(&second.stdout)?; + + assert_eq!(first.plugin_name, second.plugin_name); + assert_eq!(first.mcp_server_name, second.mcp_server_name); + assert_eq!(first.skill_name, second.skill_name); + for relative in [ + ".gitignore".to_owned(), + "LICENSE".to_owned(), + "mcp.json".to_owned(), + "plugin.json".to_owned(), + format!("skills/{}/SKILL.md", first.skill_name), + format!("skills/{}/references/operating-guide.md", first.skill_name), + ] { + assert_eq!( + std::fs::read(first_root.join("plugin").join(&relative))?, + std::fs::read(second_root.join("plugin").join(&relative))?, + "portable file differs: {relative}" + ); + } + assert_ne!( + std::fs::read(first_root.join("plugin").join(LOCAL_BINDING))?, + std::fs::read(second_root.join("plugin").join(LOCAL_BINDING))? + ); + Ok(()) +} + +#[test] +fn plugin_create_should_reject_conflicts_without_changing_them() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let (manifest, database) = fixture_workspace(temporary.path())?; + let output = temporary.path().join("portable-plugin"); + std::fs::create_dir(&output)?; + std::fs::write(output.join("owned.txt"), "keep me")?; + + let result = create_with_cli(&manifest, &database, &output, &[])?; + + assert!(!result.status.success()); + assert_eq!(result.status.code(), Some(5)); + assert_eq!( + std::fs::read_to_string(output.join("owned.txt"))?, + "keep me" + ); + assert!(!output.join("plugin.json").exists()); + Ok(()) +} + +#[test] +fn plugin_create_should_preserve_invalid_manifest_exit_code() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let (manifest, database) = fixture_workspace(temporary.path())?; + std::fs::write( + &manifest, + "version: 1\nname: ''\nrepos:\n app:\n path: répo with spaces\n", + )?; + + let result = create_with_cli( + &manifest, + &database, + &temporary.path().join("portable-plugin"), + &[], + )?; + + assert!(!result.status.success()); + assert_eq!(result.status.code(), Some(2)); + Ok(()) +} + +#[test] +fn plugin_create_should_allow_and_report_a_stale_snapshot() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let (manifest, database) = fixture_workspace(temporary.path())?; + std::fs::write( + &manifest, + "version: 1\nname: plugin-workspace\nrepos:\n app:\n path: répo with spaces\n useGitignore: true\n", + )?; + let output = temporary.path().join("stale-plugin"); + + let result = create_with_cli(&manifest, &database, &output, &[])?; + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let report: AgentPluginCreateReport = serde_json::from_slice(&result.stdout)?; + + assert!(report.changed); + assert_ne!( + report.snapshot_freshness, + code_system_graph_model::OverallFreshness::Fresh + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn plugin_create_should_reject_a_symlink_output_without_touching_target() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let (manifest, database) = fixture_workspace(temporary.path())?; + let target = temporary.path().join("target"); + std::fs::create_dir(&target)?; + let output = temporary.path().join("plugin-link"); + std::os::unix::fs::symlink(&target, &output)?; + + let result = create_with_cli(&manifest, &database, &output, &[])?; + + assert!(!result.status.success()); + assert!(std::fs::read_dir(&target)?.next().is_none()); + Ok(()) +} + +#[tokio::test] +async fn generated_mcp_should_handshake_with_read_only_profile() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let (manifest, database) = fixture_workspace(temporary.path())?; + let output = temporary.path().join("portable-plugin"); + let created = create_with_cli(&manifest, &database, &output, &[])?; + assert!(created.status.success()); + let mcp: serde_json::Value = serde_json::from_slice(&std::fs::read(output.join("mcp.json"))?)?; + let report: AgentPluginCreateReport = serde_json::from_slice(&created.stdout)?; + let arguments = expanded_mcp_arguments(&mcp, &report.mcp_server_name, &output); + let mut child = tokio::process::Command::new(env!("CARGO_BIN_EXE_csgraph")) + .args(arguments) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + let stdin = child.stdin.take().ok_or_else(|| anyhow::anyhow!("stdin"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("stdout"))?; + let client = ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("agent-plugin-e2e", env!("CARGO_PKG_VERSION")), + ); + let mut service = client.serve((stdout, stdin)).await?; + let names = service + .list_all_tools() + .await? + .into_iter() + .map(|tool| tool.name.into_owned()) + .collect::>(); + + assert!(names.contains(&"status".to_owned())); + assert!(names.contains(&"query".to_owned())); + assert!(!names.contains(&"scan".to_owned())); + assert!(!names.contains(&"explore".to_owned())); + let _ = service.close().await; + let status = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()).await??; + assert!(status.success()); + Ok(()) +} + +#[test] +fn plugin_create_should_write_the_path_resolved_codegraph_binding() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let (manifest, database) = fixture_workspace(temporary.path())?; + let output = temporary.path().join("portable-plugin-codegraph-path"); + let created = create_with_cli(&manifest, &database, &output, &["--codegraph"])?; + assert!( + created.status.success(), + "{}", + String::from_utf8_lossy(&created.stderr) + ); + let binding: serde_json::Value = serde_json::from_slice(&std::fs::read( + output.join(".local/code-system-graph/mcp-binding.json"), + )?)?; + assert!(binding["codegraphEnabled"].as_bool().expect("boolean")); + assert!(binding["codegraphBinary"].is_null()); + assert!(!std::fs::read_to_string(output.join("mcp.json"))?.contains("{{")); + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn generated_codegraph_profile_should_expose_explore_without_admin_tools() +-> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let (manifest, database) = fixture_workspace(temporary.path())?; + let binary = fake_codegraph(temporary.path())?; + let output = temporary.path().join("portable-plugin-codegraph"); + let created = Command::new(env!("CARGO_BIN_EXE_csgraph")) + .args(["plugin", "create", "--output"]) + .arg(&output) + .arg("--config") + .arg(&manifest) + .arg("--database") + .arg(&database) + .arg("--codegraph") + .arg("--codegraph-binary") + .arg(&binary) + .output()?; + assert!( + created.status.success(), + "{}", + String::from_utf8_lossy(&created.stderr) + ); + let binding_path = output.join(".local/code-system-graph/mcp-binding.json"); + let binding: serde_json::Value = serde_json::from_slice(&std::fs::read(&binding_path)?)?; + assert!(binding["codegraphEnabled"].as_bool().expect("boolean")); + assert_eq!(binding["codegraphBinary"], canonical_string(&binary)?); + let mut child = tokio::process::Command::new(env!("CARGO_BIN_EXE_csgraph")) + .args(["mcp", "--binding"]) + .arg(binding_path) + .env("CODE_SYSTEM_GRAPH_MCP_ADMIN", "0") + .env("CODE_SYSTEM_GRAPH_CODEGRAPH", "0") + .env("CODE_SYSTEM_GRAPH_CODEGRAPH_BINARY", "") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + let stdin = child.stdin.take().ok_or_else(|| anyhow::anyhow!("stdin"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("stdout"))?; + let client = ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("agent-plugin-codegraph-e2e", env!("CARGO_PKG_VERSION")), + ); + let mut service = client.serve((stdout, stdin)).await?; + let names = service + .list_all_tools() + .await? + .into_iter() + .map(|tool| tool.name.into_owned()) + .collect::>(); + + assert!(names.contains(&"explore".to_owned())); + assert!(!names.contains(&"scan".to_owned())); + let _ = service.close().await; + let status = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()).await??; + assert!(status.success()); + Ok(()) +} + +#[test] +fn plugin_create_should_compose_a_managed_integration_into_an_existing_plugin() -> anyhow::Result<()> +{ + let temporary = tempfile::tempdir()?; + let workspace = temporary.path().join("workspace"); + std::fs::create_dir(&workspace)?; + let (manifest, database) = fixture_workspace(&workspace)?; + let base = fixture_base_plugin(temporary.path())?; + let original_mcp = std::fs::read(base.join("mcp.json"))?; + let original_codex = std::fs::read(base.join(".codex-plugin/plugin.json"))?; + + let first = bind_existing_with_cli(&base, &manifest, &database, &[])?; + assert!( + first.status.success(), + "{}", + String::from_utf8_lossy(&first.stderr) + ); + let first: AgentPluginCreateReport = serde_json::from_slice(&first.stdout)?; + assert!(first.changed); + assert_eq!(first.mode, AgentPluginCreateMode::ExistingPlugin); + assert_eq!(first.plugin_name, "hugint-agent-plugin"); + assert_eq!(first.mcp_server_name, "hugint-code-system-graph"); + assert_eq!(first.skill_name, "hugint-system-graph"); + assert_ne!(std::fs::read(base.join("mcp.json"))?, original_mcp); + assert_ne!( + std::fs::read(base.join(".codex-plugin/plugin.json"))?, + original_codex + ); + let portable: serde_json::Value = + serde_json::from_slice(&std::fs::read(base.join("mcp.json"))?)?; + assert!(portable["mcpServers"]["codegraph"].is_object()); + assert!(portable["mcpServers"]["hugint-code-system-graph"].is_object()); + let binding_path = base.join(".local/code-system-graph/mcp-binding.json"); + assert_eq!(first.binding, canonical_string(&binding_path)?); + let binding: serde_json::Value = serde_json::from_slice(&std::fs::read(&binding_path)?)?; + assert_eq!(binding["generator"], "csgraph plugin binding"); + assert_generator_build(&binding["generatorBuild"]); + assert_eq!(binding["workspace"], "plugin-workspace"); + assert_eq!(binding["database"], canonical_string(&database)?); + let receipt_path = base.join(".local/code-system-graph/plugin-integration.json"); + assert!(receipt_path.is_file()); + let receipt: serde_json::Value = serde_json::from_slice(&std::fs::read(receipt_path)?)?; + assert_generator_build(&receipt["generatorBuild"]); + assert!(base.join("skills/hugint-system-graph/SKILL.md").is_file()); + assert_openai_skill_metadata( + &base.join("skills/hugint-system-graph/agents/openai.yaml"), + "Code System Graph (plugin-workspace)", + "hugint-system-graph", + )?; + assert!(!base.join(".local/assembled").exists()); + assert!(!base.join("skills/code-system-graph").exists()); + + let second = bind_existing_with_cli(&base, &manifest, &database, &[])?; + assert!(second.status.success()); + let second: AgentPluginCreateReport = serde_json::from_slice(&second.stdout)?; + assert!(!second.changed); + Ok(()) +} + +#[test] +fn existing_portable_plugin_should_not_receive_codex_only_skill_metadata() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let workspace = temporary.path().join("workspace"); + std::fs::create_dir(&workspace)?; + let (manifest, database) = fixture_workspace(&workspace)?; + let base = fixture_base_plugin(temporary.path())?; + std::fs::remove_file(base.join(".codex-plugin/plugin.json"))?; + + let created = bind_existing_with_cli(&base, &manifest, &database, &[])?; + + assert!( + created.status.success(), + "{}", + String::from_utf8_lossy(&created.stderr) + ); + assert!(base.join("skills/hugint-system-graph/SKILL.md").is_file()); + assert!( + !base + .join("skills/hugint-system-graph/agents/openai.yaml") + .exists() + ); + Ok(()) +} + +#[test] +fn plugin_uninstall_should_remove_only_unchanged_managed_components_and_allow_reinstall() +-> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let workspace = temporary.path().join("workspace"); + std::fs::create_dir(&workspace)?; + let (manifest, database) = fixture_workspace(&workspace)?; + let base = fixture_base_plugin(temporary.path())?; + let original_mcp = std::fs::read(base.join("mcp.json"))?; + let original_codex = std::fs::read(base.join(".codex-plugin/plugin.json"))?; + + assert!( + bind_existing_with_cli(&base, &manifest, &database, &[])? + .status + .success() + ); + let removed = uninstall_existing_with_cli(&base)?; + assert!( + removed.status.success(), + "{}", + String::from_utf8_lossy(&removed.stderr) + ); + let report: AgentPluginUninstallReport = serde_json::from_slice(&removed.stdout)?; + assert!(report.changed); + assert_eq!(report.plugin_name, "hugint-agent-plugin"); + assert_eq!( + serde_json::from_slice::(&std::fs::read(base.join("mcp.json"))?)?, + serde_json::from_slice::(&original_mcp)? + ); + assert_eq!( + serde_json::from_slice::(&std::fs::read( + base.join(".codex-plugin/plugin.json") + )?)?, + serde_json::from_slice::(&original_codex)? + ); + assert!(!base.join("skills/hugint-system-graph").exists()); + assert!(!base.join(".local/code-system-graph").exists()); + assert_eq!( + std::fs::read_to_string(base.join("README.md"))?, + "portable base\n" + ); + + let reinstalled = bind_existing_with_cli(&base, &manifest, &database, &[])?; + assert!( + reinstalled.status.success(), + "{}", + String::from_utf8_lossy(&reinstalled.stderr) + ); + assert!(base.join("skills/hugint-system-graph/SKILL.md").is_file()); + assert!(base.join(LOCAL_BINDING).is_file()); + Ok(()) +} + +#[test] +fn plugin_uninstall_should_refuse_to_delete_a_modified_managed_skill() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let workspace = temporary.path().join("workspace"); + std::fs::create_dir(&workspace)?; + let (manifest, database) = fixture_workspace(&workspace)?; + let base = fixture_base_plugin(temporary.path())?; + assert!( + bind_existing_with_cli(&base, &manifest, &database, &[])? + .status + .success() + ); + let skill = base.join("skills/hugint-system-graph/SKILL.md"); + std::fs::write(&skill, "user-owned replacement\n")?; + + let refused = uninstall_existing_with_cli(&base)?; + assert!(!refused.status.success()); + assert_eq!(refused.status.code(), Some(5)); + assert_eq!(std::fs::read_to_string(&skill)?, "user-owned replacement\n"); + assert!(base.join(LOCAL_BINDING).is_file()); + let portable: serde_json::Value = + serde_json::from_slice(&std::fs::read(base.join("mcp.json"))?)?; + assert!(portable["mcpServers"]["hugint-code-system-graph"].is_object()); + Ok(()) +} + +#[test] +fn plugin_uninstall_should_preserve_unowned_local_files_and_modified_bindings() -> anyhow::Result<()> +{ + let temporary = tempfile::tempdir()?; + let workspace = temporary.path().join("workspace"); + std::fs::create_dir(&workspace)?; + let (manifest, database) = fixture_workspace(&workspace)?; + let base = fixture_base_plugin(temporary.path())?; + assert!( + bind_existing_with_cli(&base, &manifest, &database, &[])? + .status + .success() + ); + let local_root = base.join(".local/code-system-graph"); + let binding = local_root.join("mcp-binding.json"); + let original_binding = std::fs::read(&binding)?; + std::fs::write(local_root.join("user-owned.txt"), "keep\n")?; + + let unowned = uninstall_existing_with_cli(&base)?; + assert!(!unowned.status.success()); + assert_eq!( + std::fs::read_to_string(local_root.join("user-owned.txt"))?, + "keep\n" + ); + assert!(base.join("skills/hugint-system-graph/SKILL.md").is_file()); + + std::fs::remove_file(local_root.join("user-owned.txt"))?; + let mut changed: serde_json::Value = serde_json::from_slice(&original_binding)?; + changed["workspace"] = serde_json::json!("modified-workspace"); + std::fs::write(&binding, serde_json::to_vec_pretty(&changed)?)?; + let modified = uninstall_existing_with_cli(&base)?; + assert!(!modified.status.success()); + assert_eq!( + serde_json::from_slice::(&std::fs::read(&binding)?)?["workspace"], + "modified-workspace" + ); + assert!(base.join("skills/hugint-system-graph/SKILL.md").is_file()); + Ok(()) +} + +#[test] +fn plugin_uninstall_should_ignore_a_codex_manifest_added_after_installation() -> anyhow::Result<()> +{ + let temporary = tempfile::tempdir()?; + let workspace = temporary.path().join("workspace"); + std::fs::create_dir(&workspace)?; + let (manifest, database) = fixture_workspace(&workspace)?; + let base = fixture_base_plugin(temporary.path())?; + let codex_path = base.join(".codex-plugin/plugin.json"); + let independently_added = std::fs::read(&codex_path)?; + std::fs::remove_file(&codex_path)?; + assert!( + bind_existing_with_cli(&base, &manifest, &database, &[])? + .status + .success() + ); + std::fs::write(&codex_path, &independently_added)?; + + let removed = uninstall_existing_with_cli(&base)?; + assert!( + removed.status.success(), + "{}", + String::from_utf8_lossy(&removed.stderr) + ); + assert_eq!(std::fs::read(&codex_path)?, independently_added); + assert!(!base.join("skills/hugint-system-graph").exists()); + assert!(!base.join(".local/code-system-graph").exists()); + Ok(()) +} + +#[test] +fn plugin_create_should_replace_only_an_owned_existing_plugin_binding() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let workspace = temporary.path().join("workspace"); + std::fs::create_dir(&workspace)?; + let (manifest, database) = fixture_workspace(&workspace)?; + let base = fixture_base_plugin(temporary.path())?; + assert!( + bind_existing_with_cli(&base, &manifest, &database, &[])? + .status + .success() + ); + let binding_path = base.join(".local/code-system-graph/mcp-binding.json"); + let mut binding: serde_json::Value = serde_json::from_slice(&std::fs::read(&binding_path)?)?; + binding["generator"] = serde_json::json!("csgraph plugin compose"); + binding["workspace"] = serde_json::json!("locally-edited"); + binding + .as_object_mut() + .expect("binding object") + .remove("generatorBuild"); + std::fs::write(&binding_path, serde_json::to_vec_pretty(&binding)?)?; + + let conflict = bind_existing_with_cli(&base, &manifest, &database, &[])?; + assert!(!conflict.status.success()); + let unchanged: serde_json::Value = serde_json::from_slice(&std::fs::read(&binding_path)?)?; + assert_eq!(unchanged["workspace"], "locally-edited"); + + let replaced = bind_existing_with_cli(&base, &manifest, &database, &["--replace-generated"])?; + assert!( + replaced.status.success(), + "{}", + String::from_utf8_lossy(&replaced.stderr) + ); + let restored: serde_json::Value = serde_json::from_slice(&std::fs::read(&binding_path)?)?; + assert_eq!(restored["workspace"], "plugin-workspace"); + Ok(()) +} + +#[test] +fn plugin_create_should_not_replace_unmanaged_local_state() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let workspace = temporary.path().join("workspace"); + std::fs::create_dir(&workspace)?; + let (manifest, database) = fixture_workspace(&workspace)?; + let base = fixture_base_plugin(temporary.path())?; + let output = base.join(".local/code-system-graph"); + std::fs::create_dir_all(&output)?; + std::fs::write(output.join("owned-by-user.txt"), "keep\n")?; + + let result = bind_existing_with_cli(&base, &manifest, &database, &["--replace-generated"])?; + assert!(!result.status.success()); + assert_eq!( + std::fs::read_to_string(output.join("owned-by-user.txt"))?, + "keep\n" + ); + Ok(()) +} + +#[tokio::test] +async fn existing_plugin_binding_should_start_the_read_only_mcp() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let workspace = temporary.path().join("workspace"); + std::fs::create_dir(&workspace)?; + let (manifest, database) = fixture_workspace(&workspace)?; + let base = fixture_base_plugin(temporary.path())?; + let created = bind_existing_with_cli(&base, &manifest, &database, &[])?; + assert!(created.status.success()); + let binding = base.join(".local/code-system-graph/mcp-binding.json"); + let mut child = tokio::process::Command::new(env!("CARGO_BIN_EXE_csgraph")) + .args(["mcp", "--binding"]) + .arg(binding) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + let stdin = child.stdin.take().ok_or_else(|| anyhow::anyhow!("stdin"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("stdout"))?; + let client = ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("agent-plugin-binding-e2e", env!("CARGO_PKG_VERSION")), + ); + let mut service = client.serve((stdout, stdin)).await?; + let names = service + .list_all_tools() + .await? + .into_iter() + .map(|tool| tool.name.into_owned()) + .collect::>(); + assert!(names.contains(&"status".to_owned())); + assert!(!names.contains(&"scan".to_owned())); + assert!(!names.contains(&"explore".to_owned())); + let _ = service.close().await; + let status = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()).await??; + assert!(status.success()); + Ok(()) +} diff --git a/crates/code-system-graph-cli/tests/changes_e2e.rs b/crates/code-system-graph-cli/tests/changes_e2e.rs index c46202c..13848d7 100644 --- a/crates/code-system-graph-cli/tests/changes_e2e.rs +++ b/crates/code-system-graph-cli/tests/changes_e2e.rs @@ -37,6 +37,7 @@ async fn local_changes_should_be_fingerprinted_read_only_and_source_free() -> an "pub fn answer() -> u32 {\n 41\n}\n", )?; git(&repository, &["init", "--quiet"])?; + git(&repository, &["config", "core.autocrlf", "false"])?; git(&repository, &["add", "."])?; git( &repository, diff --git a/crates/code-system-graph-cli/tests/config_show_e2e.rs b/crates/code-system-graph-cli/tests/config_show_e2e.rs index 3fbdaef..150ef66 100644 --- a/crates/code-system-graph-cli/tests/config_show_e2e.rs +++ b/crates/code-system-graph-cli/tests/config_show_e2e.rs @@ -1,8 +1,9 @@ //! End-to-end coverage for effective native discovery configuration. +use std::num::NonZeroUsize; use std::process::Command; -use code_system_graph::ConfigReport; +use code_system_graph::ExtendedConfigReport; use code_system_graph_core::{ExecutionPolicy, ExtractionBudgets}; #[test] @@ -25,7 +26,7 @@ fn config_show_should_report_defaults_and_selected_repository_rules() -> anyhow: .arg(&manifest) .args(["--repo", "api"]) .output()?; - let report: ConfigReport = serde_json::from_slice(&output.stdout)?; + let report: ExtendedConfigReport = serde_json::from_slice(&output.stdout)?; assert!( output.status.success(), @@ -35,13 +36,18 @@ fn config_show_should_report_defaults_and_selected_repository_rules() -> anyhow: assert_eq!(report.schema_version, 1); assert_eq!(report.workspace, "configuration"); assert_eq!(report.extraction_budgets, ExtractionBudgets::default()); - assert_eq!(report.execution_policy, ExecutionPolicy::default()); + assert_eq!(report.execution_policy.base, ExecutionPolicy::default()); assert_eq!( report.execution_policy_fingerprint, - ExecutionPolicy::default().fingerprint() + report.execution_policy.fingerprint() ); assert_eq!(report.repositories.len(), 1); assert_eq!(report.repositories[0].alias, "api"); + assert!(!report.repositories[0].ignore_policy.use_gitignore.value); + assert!(matches!( + report.repositories[0].ignore_policy.use_gitignore.source, + code_system_graph::ConfigSource::Default + )); assert_eq!( report.repositories[0] .ignore_policy @@ -80,18 +86,26 @@ fn config_show_should_report_effective_execution_policy() -> anyhow::Result<()> let manifest = temporary.path().join("code-system-graph.yaml"); std::fs::write( &manifest, - "version: 1\nname: configuration\nexecutionPolicy:\n maxScanWallTimeMs: 28800000\n maxNoProgressTimeMs: 600000\nrepos:\n api:\n path: api\n", + "version: 1\nname: configuration\nexecutionPolicy:\n maxScanWallTimeMs: 28800000\n maxNoProgressTimeMs: 600000\n maxCodeGraphCorroborationAnchorsPerRepo: 12\nrepos:\n api:\n path: api\n", )?; let output = Command::new(env!("CARGO_BIN_EXE_csgraph")) .args(["config", "show", "--config"]) .arg(&manifest) .output()?; - let report: ConfigReport = serde_json::from_slice(&output.stdout)?; + let report: ExtendedConfigReport = serde_json::from_slice(&output.stdout)?; assert!(output.status.success()); assert_eq!(report.execution_policy.max_scan_wall_time_ms, 28_800_000); assert_eq!(report.execution_policy.max_no_progress_time_ms, 600_000); + assert_eq!( + report + .execution_policy + .max_codegraph_corroboration_anchors_per_repo + .bounded() + .map(NonZeroUsize::get), + Some(12) + ); assert_eq!( report.execution_policy.max_worker_memory_bytes, ExecutionPolicy::default().max_worker_memory_bytes @@ -117,7 +131,7 @@ fn config_show_should_report_effective_budget_overrides() -> anyhow::Result<()> .args(["config", "show", "--config"]) .arg(&manifest) .output()?; - let report: ConfigReport = serde_json::from_slice(&output.stdout)?; + let report: ExtendedConfigReport = serde_json::from_slice(&output.stdout)?; assert!(output.status.success()); assert_eq!( @@ -220,7 +234,7 @@ fn config_show_should_report_repository_local_rule_source() -> anyhow::Result<() .args(["config", "show", "--config"]) .arg(&manifest) .output()?; - let report: ConfigReport = serde_json::from_slice(&output.stdout)?; + let report: ExtendedConfigReport = serde_json::from_slice(&output.stdout)?; assert!(matches!( report.repositories[0] @@ -232,6 +246,35 @@ fn config_show_should_report_repository_local_rule_source() -> anyhow::Result<() Ok(()) } +#[test] +fn config_show_should_report_repository_local_gitignore_source() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + std::fs::create_dir_all(temporary.path().join("worker"))?; + std::fs::write( + temporary.path().join("worker/.code-system-graph.yaml"), + "version: 1\nuseGitignore: true\n", + )?; + let manifest = temporary.path().join("code-system-graph.yaml"); + std::fs::write( + &manifest, + "version: 1\nname: configuration\nrepos:\n worker:\n path: worker\n", + )?; + + let output = Command::new(env!("CARGO_BIN_EXE_csgraph")) + .args(["config", "show", "--config"]) + .arg(&manifest) + .output()?; + let report: ExtendedConfigReport = serde_json::from_slice(&output.stdout)?; + + assert!(output.status.success()); + assert!(report.repositories[0].ignore_policy.use_gitignore.value); + assert!(matches!( + report.repositories[0].ignore_policy.use_gitignore.source, + code_system_graph::ConfigSource::RepositoryLocal + )); + Ok(()) +} + #[test] fn config_show_should_reject_unknown_repository_alias() -> anyhow::Result<()> { let temporary = tempfile::tempdir()?; diff --git a/crates/code-system-graph-cli/tests/http_server_e2e.rs b/crates/code-system-graph-cli/tests/http_server_e2e.rs index d52bcaf..8c70aaf 100644 --- a/crates/code-system-graph-cli/tests/http_server_e2e.rs +++ b/crates/code-system-graph-cli/tests/http_server_e2e.rs @@ -12,6 +12,7 @@ use code_system_graph::http_server::{ BearerToken, DEFAULT_HTTP_BIND, HttpServerConfig, create_router, serve_http_on_listener }; use code_system_graph::scan_workspace; +#[cfg(unix)] use code_system_graph_store_sqlite::SqliteStore; use reqwest::{Client, StatusCode}; use serde_json::{Value, json}; @@ -82,6 +83,7 @@ impl Fixture { } fn server_config(&self) -> HttpServerConfig { + debug_assert!(self.temporary.path().is_dir()); HttpServerConfig::new(&self.manifest, &self.database, &self.workspace_name) } } diff --git a/crates/code-system-graph-cli/tests/ignore_policy_e2e.rs b/crates/code-system-graph-cli/tests/ignore_policy_e2e.rs index cc82a4b..4fdbbf9 100644 --- a/crates/code-system-graph-cli/tests/ignore_policy_e2e.rs +++ b/crates/code-system-graph-cli/tests/ignore_policy_e2e.rs @@ -86,3 +86,35 @@ fn scan_should_invalidate_incremental_state_when_policy_changes() -> anyhow::Res assert!(after.discovered_input_count < before.discovered_input_count); Ok(()) } + +#[test] +fn scan_should_apply_gitignore_but_keep_explicit_artifacts() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let repository = temporary.path().join("repo"); + std::fs::create_dir_all(repository.join("ignored"))?; + std::fs::write(repository.join(".gitignore"), "ignored/*\n")?; + std::fs::write(repository.join("ignored/hidden.rs"), "pub fn hidden() {}\n")?; + std::fs::write( + repository.join("ignored/openapi.yaml"), + "openapi: 3.1.0\ninfo:\n title: Explicit\n version: 1\npaths: {}\n", + )?; + let manifest = temporary.path().join("code-system-graph.yaml"); + std::fs::write( + &manifest, + "version: 1\nname: gitignore\nrepos:\n app:\n path: repo\n useGitignore: true\n openapi: ignored/openapi.yaml\n", + )?; + let database = temporary.path().join("graph.db"); + + scan_workspace(&manifest, &database)?; + let paths = SqliteStore::open_read_only(&database)? + .load_current_artifact_fingerprints("gitignore")? + .into_iter() + .map(|fingerprint| fingerprint.path) + .collect::>(); + let native_path = + |relative: &str| encode_native_path(&relative.split('/').collect::()); + + assert!(!paths.contains(&native_path("ignored/hidden.rs"))); + assert!(paths.contains(&native_path("ignored/openapi.yaml"))); + Ok(()) +} diff --git a/crates/code-system-graph-cli/tests/interfaces_e2e.rs b/crates/code-system-graph-cli/tests/interfaces_e2e.rs index 3a2664a..9f30c73 100644 --- a/crates/code-system-graph-cli/tests/interfaces_e2e.rs +++ b/crates/code-system-graph-cli/tests/interfaces_e2e.rs @@ -50,7 +50,7 @@ fn contracts_exports_and_doctor_should_share_source_free_snapshot_services() -> let doctor = doctor_workspace(&fixture, &database)?; let serialized = serde_json::to_string(&contracts)?; - assert!(!contracts.contracts.is_empty()); + assert_ne!(contracts.contracts.as_slice(), &[]); assert!(json.content.starts_with('{')); assert!(graphml.content.starts_with(" ]) .output()?; assert_eq!(output.status.code(), Some(3)); - assert!(output.stdout.is_empty()); + assert_eq!(output.stdout, Vec::::new()); Ok(()) } @@ -84,7 +84,7 @@ fn cli_should_generate_completions_without_protocol_noise() -> anyhow::Result<() .output()?; assert!(output.status.success()); - assert!(output.stderr.is_empty()); + assert_eq!(output.stderr, Vec::::new()); assert!(String::from_utf8(output.stdout)?.contains("_csgraph")); Ok(()) } diff --git a/crates/code-system-graph-cli/tests/scan_controls_e2e.rs b/crates/code-system-graph-cli/tests/scan_controls_e2e.rs index e88b1b7..fc9e6af 100644 --- a/crates/code-system-graph-cli/tests/scan_controls_e2e.rs +++ b/crates/code-system-graph-cli/tests/scan_controls_e2e.rs @@ -1,5 +1,6 @@ //! Acceptance tests for targeted and forced scan controls. +#[cfg(unix)] use std::fmt::Write as _; use code_system_graph::{ diff --git a/crates/code-system-graph-cli/tests/sync_e2e.rs b/crates/code-system-graph-cli/tests/sync_e2e.rs index 90c2ce1..710815a 100644 --- a/crates/code-system-graph-cli/tests/sync_e2e.rs +++ b/crates/code-system-graph-cli/tests/sync_e2e.rs @@ -283,6 +283,88 @@ fn assert_watch_sync(extra_arguments: &[&str]) -> anyhow::Result<()> { result } +#[cfg(target_os = "linux")] +#[test] +fn native_watch_should_reload_gitignore_before_filtering_future_events() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let (manifest, database) = write_workspace(temporary.path())?; + let repository = temporary.path().join("api"); + std::fs::create_dir(repository.join("src"))?; + let source = repository.join("src/lib.rs"); + std::fs::write(&source, "pub fn before() {}\n")?; + std::fs::write(repository.join(".gitignore"), "src/\n")?; + std::fs::write( + &manifest, + "version: 1\nname: sync-e2e\nrepos:\n api:\n path: api\n openapi: openapi.yaml\n useGitignore: true\n", + )?; + let mut child = Command::new(env!("CARGO_BIN_EXE_csgraph")) + .arg("sync") + .arg("--watch") + .arg("--no-codegraph") + .arg("--debounce-ms") + .arg("100") + .arg("--config") + .arg(&manifest) + .arg("--database") + .arg(&database) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("watch stdout was not piped"))?; + let (sender, receiver) = mpsc::channel(); + let reader = std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + if sender.send(line).is_err() { + break; + } + } + }); + + let result = (|| -> anyhow::Result<()> { + let initial = receiver + .recv_timeout(Duration::from_secs(15)) + .context("watch did not publish its initial pass")??; + let initial = watch_sync_summary(&initial)?; + std::fs::write(repository.join(".gitignore"), "")?; + let unignored = next_changed_watch_summary(&receiver, &initial)?; + + std::fs::write(&source, "pub fn after() {}\n")?; + let edited = next_changed_watch_summary(&receiver, &unignored)?; + + assert_ne!(unignored.scan.snapshot_id, initial.scan.snapshot_id); + assert_ne!(edited.scan.snapshot_id, unignored.scan.snapshot_id); + Ok(()) + })(); + + child.kill()?; + let _status = child.wait()?; + reader + .join() + .map_err(|_| anyhow::anyhow!("watch stdout reader panicked"))?; + result +} + +#[cfg(target_os = "linux")] +fn next_changed_watch_summary( + receiver: &mpsc::Receiver>, + previous: &SyncSummary, +) -> anyhow::Result { + loop { + let candidate = receiver + .recv_timeout(Duration::from_secs(15)) + .context("watch did not publish after the expected change")??; + let candidate = watch_sync_summary(&candidate)?; + if candidate.scan.changed_input_count > 0 + && candidate.scan.snapshot_id != previous.scan.snapshot_id + { + return Ok(candidate); + } + } +} + #[test] fn watch_failure_should_not_persist_or_emit_parser_literals() -> anyhow::Result<()> { let temporary = tempfile::tempdir()?; diff --git a/crates/code-system-graph-core/Cargo.toml b/crates/code-system-graph-core/Cargo.toml index d3475b9..e7d86d1 100644 --- a/crates/code-system-graph-core/Cargo.toml +++ b/crates/code-system-graph-core/Cargo.toml @@ -18,11 +18,12 @@ blake3 = "1.8.5" graphql-parser = "0.4.1" globset = "0.4.19" hcl-rs = "0.19.7" +ignore = "0.4.31" libc = "0.2" nix = { version = "0.31.3", features = ["fs"] } proto-parser = "1.14.3" pulldown-cmark = "0.13.4" -code-system-graph-model = { version = "1.0.2", path = "../code-system-graph-model" } +code-system-graph-model = { version = "1.0.3", path = "../code-system-graph-model" } reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] } rmcp = { version = "3.1.0", default-features = false, features = [ "client", diff --git a/crates/code-system-graph-core/src/change_analysis.rs b/crates/code-system-graph-core/src/change_analysis.rs index efa55df..f5a3308 100644 --- a/crates/code-system-graph-core/src/change_analysis.rs +++ b/crates/code-system-graph-core/src/change_analysis.rs @@ -1688,7 +1688,7 @@ mod tests { &evidence_refs, ); - assert!(result.mapping.matched_evidence_ids.is_empty()); + assert_eq!(result.mapping.matched_evidence_ids, Vec::new()); } #[test] @@ -1705,7 +1705,7 @@ mod tests { &evidence_refs, ); - assert!(result.mapping.matched_evidence_ids.is_empty()); + assert_eq!(result.mapping.matched_evidence_ids, Vec::new()); } #[test] diff --git a/crates/code-system-graph-core/src/changes.rs b/crates/code-system-graph-core/src/changes.rs index 815d22a..1d8d782 100644 --- a/crates/code-system-graph-core/src/changes.rs +++ b/crates/code-system-graph-core/src/changes.rs @@ -212,6 +212,7 @@ pub struct ChangeRequest { } /// Read-only source of repository changes. +#[allow(clippy::double_must_use)] #[async_trait] pub trait ChangeProvider: Send + Sync { /// Collects a bounded and fingerprinted change set. @@ -1567,6 +1568,7 @@ mod tests { fn repo() -> Result> { let temp = tempfile::tempdir()?; git(temp.path(), &["init", "-q"])?; + git(temp.path(), &["config", "core.autocrlf", "false"])?; git( temp.path(), &["config", "user.name", "Code System Graph Test"], diff --git a/crates/code-system-graph-core/src/codegraph/contract.rs b/crates/code-system-graph-core/src/codegraph/contract.rs index 586e042..9c6d328 100644 --- a/crates/code-system-graph-core/src/codegraph/contract.rs +++ b/crates/code-system-graph-core/src/codegraph/contract.rs @@ -293,7 +293,7 @@ mod tests { )) .expect("fixture should be valid"); - assert!(map_mcp_tools(&tools).is_empty()); + assert_eq!(map_mcp_tools(&tools), Vec::new()); } #[test] diff --git a/crates/code-system-graph-core/src/config.rs b/crates/code-system-graph-core/src/config.rs index a845475..8300355 100644 --- a/crates/code-system-graph-core/src/config.rs +++ b/crates/code-system-graph-core/src/config.rs @@ -1,5 +1,4 @@ use std::collections::BTreeMap; -use std::fs; use std::path::{Path, PathBuf}; use code_system_graph_model::stable_id; @@ -7,8 +6,9 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use thiserror::Error; +use crate::ignore_policy::discover_repository_files_matching; use crate::{ - CapabilityDir, CapabilityError, ContractImplementationConfig, HttpConsumerConfig, IgnorePatternError, IgnorePolicy, IntegrationTestConfig, MAX_REPOSITORY_CONFIG_BYTES, RegularFileEntry, RepositoryConfig, validate_excludes, validate_include_defaults + CapabilityDir, CapabilityError, ContractImplementationConfig, HttpConsumerConfig, IgnorePatternError, IgnorePolicy, IntegrationTestConfig, MAX_REPOSITORY_CONFIG_BYTES, RegularFileEntry, RepositoryConfig, RepositoryDiscoveryError, validate_excludes, validate_include_defaults }; const LOCAL_CONFIG_NAME: &str = ".code-system-graph.yaml"; @@ -91,6 +91,7 @@ struct RepositoryLocalConfig { implementations: Option>, excludes: Option>, include_defaults: Option>, + use_gitignore: Option, } /// Error returned while resolving repository configuration precedence. @@ -147,6 +148,9 @@ pub enum ConfigError { #[source] source: IgnorePatternError, }, + /// Automatic repository discovery or an enabled `.gitignore` file failed. + #[error(transparent)] + Discovery(#[from] RepositoryDiscoveryError), /// Repository-local configuration is a symbolic link or reparse point. #[error("repository config `{path}` is a symbolic link or reparse point")] Symlink { @@ -189,6 +193,22 @@ pub enum ConfigError { pub fn resolve_repository_config( checkout_path: &Path, workspace: &RepositoryConfig, +) -> Result { + resolve_repository_config_with_use_gitignore(checkout_path, workspace, None) +} + +/// Resolves repository configuration with an additive workspace-level `.gitignore` choice. +/// +/// This companion API keeps [`RepositoryConfig`] exhaustively constructible for patch-version +/// source compatibility. +/// +/// # Errors +/// +/// Returns the same errors as [`resolve_repository_config`]. +pub fn resolve_repository_config_with_use_gitignore( + checkout_path: &Path, + workspace: &RepositoryConfig, + workspace_use_gitignore: Option, ) -> Result { let checkout = CapabilityDir::open(checkout_path) .map_err(|error| map_capability_error(error, checkout_path, LOCAL_CONFIG_NAME))?; @@ -213,7 +233,12 @@ pub fn resolve_repository_config( } }; - let ignore_policy = resolve_ignore_policy(checkout_path, workspace, local.as_ref())?; + let ignore_policy = resolve_ignore_policy( + checkout_path, + workspace, + workspace_use_gitignore, + local.as_ref(), + )?; let (openapi, openapi_source) = if let Some(openapi) = &workspace.openapi { (vec![openapi.clone()], ConfigSource::WorkspaceManifest) } else if let Some(openapi) = local.as_ref().and_then(|config| config.openapi.clone()) { @@ -307,50 +332,20 @@ fn discover_openapi_candidates( root: &Path, ignore_policy: &IgnorePolicy, ) -> Result, ConfigError> { - let mut pending = vec![(root.to_path_buf(), 0_usize)]; let mut candidates = Vec::new(); - while let Some((directory, depth)) = pending.pop() { - let entries = fs::read_dir(&directory).map_err(|source| ConfigError::Read { - path: directory.clone(), - source, - })?; - let mut entries = - entries - .collect::, _>>() - .map_err(|source| ConfigError::Read { - path: directory.clone(), - source, - })?; - entries.sort_by_key(fs::DirEntry::file_name); - for entry in entries { - let file_type = entry.file_type().map_err(|source| ConfigError::Read { - path: entry.path(), - source, - })?; - let name = entry.file_name().to_string_lossy().to_string(); - let path = entry.path(); - let relative = path.strip_prefix(root).unwrap_or(path.as_path()); - if file_type.is_dir() { - if depth < MAX_OPENAPI_DISCOVERY_DEPTH && !ignore_policy.excludes(relative, true) { - pending.push((path, depth.saturating_add(1))); - } - continue; - } - if !file_type.is_file() - || ignore_policy.excludes(relative, false) - || !openapi_filename(&name) - { - continue; - } - let relative = relative.to_string_lossy().replace('\\', "/"); - candidates.push(relative); - if candidates.len() >= MAX_OPENAPI_CANDIDATES { - break; - } - } - if candidates.len() >= MAX_OPENAPI_CANDIDATES { - break; - } + for relative in discover_repository_files_matching( + root, + ignore_policy, + Some(MAX_OPENAPI_DISCOVERY_DEPTH.saturating_add(1)), + Some(MAX_OPENAPI_CANDIDATES), + |relative| { + relative + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(openapi_filename) + }, + )? { + candidates.push(relative.to_string_lossy().replace('\\', "/")); } candidates.sort(); candidates.dedup(); @@ -496,9 +491,20 @@ fn select_patterns( } } +fn select_flag(workspace: Option, local: Option) -> (bool, ConfigSource) { + if let Some(value) = workspace { + (value, ConfigSource::WorkspaceManifest) + } else if let Some(value) = local { + (value, ConfigSource::RepositoryLocal) + } else { + (false, ConfigSource::Default) + } +} + fn resolve_ignore_policy( checkout_path: &Path, workspace: &RepositoryConfig, + workspace_use_gitignore: Option, local: Option<&RepositoryLocalConfig>, ) -> Result { let (excludes, excludes_source) = select_patterns( @@ -509,11 +515,17 @@ fn resolve_ignore_policy( workspace.include_defaults.as_ref(), local.and_then(|config| config.include_defaults.as_ref()), ); - IgnorePolicy::new( + let (use_gitignore, use_gitignore_source) = select_flag( + workspace_use_gitignore, + local.and_then(|config| config.use_gitignore), + ); + IgnorePolicy::with_gitignore( excludes, excludes_source, include_defaults, include_defaults_source, + use_gitignore, + use_gitignore_source, ) .map_err(|source| ConfigError::InvalidIgnorePattern { path: checkout_path.to_path_buf(), @@ -526,7 +538,9 @@ fn resolve_ignore_policy( mod tests { use std::fs; - use super::{ConfigError, ConfigSource, resolve_repository_config}; + use super::{ + ConfigError, ConfigSource, resolve_repository_config, resolve_repository_config_with_use_gitignore + }; use crate::{HttpConsumerConfig, RepositoryConfig}; #[test] @@ -607,6 +621,94 @@ mod tests { Ok(()) } + #[test] + fn workspace_use_gitignore_should_override_repository_local_value() + -> Result<(), Box> { + let repository = tempfile::tempdir()?; + fs::write( + repository.path().join(".code-system-graph.yaml"), + "version: 1\nuseGitignore: true\n", + )?; + let workspace = RepositoryConfig { + path: ".".to_owned(), + openapi: None, + http_consumers: None, + integration_tests: None, + implementations: None, + excludes: None, + include_defaults: None, + }; + + let resolved = resolve_repository_config_with_use_gitignore( + repository.path(), + &workspace, + Some(false), + )?; + + assert!(!resolved.ignore_policy.use_gitignore()); + assert_eq!( + resolved.ignore_policy.use_gitignore_source(), + ConfigSource::WorkspaceManifest + ); + Ok(()) + } + + #[test] + fn repository_local_use_gitignore_should_apply_when_workspace_omits_it() + -> Result<(), Box> { + let repository = tempfile::tempdir()?; + fs::write( + repository.path().join(".code-system-graph.yaml"), + "version: 1\nuseGitignore: true\n", + )?; + let workspace = RepositoryConfig { + path: ".".to_owned(), + openapi: None, + http_consumers: None, + integration_tests: None, + implementations: None, + excludes: None, + include_defaults: None, + }; + + let resolved = resolve_repository_config(repository.path(), &workspace)?; + + assert!(resolved.ignore_policy.use_gitignore()); + assert_eq!( + resolved.ignore_policy.use_gitignore_source(), + ConfigSource::RepositoryLocal + ); + Ok(()) + } + + #[test] + fn openapi_auto_detection_should_respect_gitignore_when_enabled() + -> Result<(), Box> { + let repository = tempfile::tempdir()?; + fs::write(repository.path().join(".gitignore"), "ignored/\n")?; + fs::create_dir(repository.path().join("ignored"))?; + fs::write(repository.path().join("ignored/openapi.yaml"), "{}")?; + fs::write(repository.path().join("openapi.json"), "{}")?; + let workspace = RepositoryConfig { + path: ".".to_owned(), + openapi: None, + http_consumers: None, + integration_tests: None, + implementations: None, + excludes: None, + include_defaults: None, + }; + + let resolved = resolve_repository_config_with_use_gitignore( + repository.path(), + &workspace, + Some(true), + )?; + + assert_eq!(resolved.openapi, vec!["openapi.json"]); + Ok(()) + } + #[test] fn canonical_equivalent_patterns_should_produce_the_same_repository_fingerprint() -> Result<(), Box> { diff --git a/crates/code-system-graph-core/src/corroboration.rs b/crates/code-system-graph-core/src/corroboration.rs index 658d1d1..2264c04 100644 --- a/crates/code-system-graph-core/src/corroboration.rs +++ b/crates/code-system-graph-core/src/corroboration.rs @@ -442,7 +442,7 @@ mod tests { report.symbols.as_slice(), [SymbolCorroboration::Unresolved { .. }] )); - assert!(report.affected_tests.is_empty()); + assert_eq!(report.affected_tests, Vec::::new()); assert_eq!(provider.operation_calls.load(Ordering::Relaxed), 0); } } diff --git a/crates/code-system-graph-core/src/documents.rs b/crates/code-system-graph-core/src/documents.rs index a0f1533..0a6a8fc 100644 --- a/crates/code-system-graph-core/src/documents.rs +++ b/crates/code-system-graph-core/src/documents.rs @@ -1366,7 +1366,7 @@ mod tests { "# Billing service\nThe payments repository calls an API.\n", ); - assert!(result.records[0].references.is_empty()); + assert_eq!(result.records[0].references, Vec::new()); } #[test] diff --git a/crates/code-system-graph-core/src/events.rs b/crates/code-system-graph-core/src/events.rs index d3c7a8f..b890476 100644 --- a/crates/code-system-graph-core/src/events.rs +++ b/crates/code-system-graph-core/src/events.rs @@ -2330,7 +2330,7 @@ channels: r#"let example = "event_bus.publish(\"orders\", payload)";"#, ); - assert!(document.observations.is_empty()); + assert_eq!(document.observations, Vec::new()); } #[test] diff --git a/crates/code-system-graph-core/src/execution_policy.rs b/crates/code-system-graph-core/src/execution_policy.rs index 604bcb4..f23cfec 100644 --- a/crates/code-system-graph-core/src/execution_policy.rs +++ b/crates/code-system-graph-core/src/execution_policy.rs @@ -1,3 +1,5 @@ +use std::fmt::Write as _; +use std::num::NonZeroUsize; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -12,6 +14,8 @@ pub const DEFAULT_MAX_SCAN_WALL_TIME_MS: u64 = 21_600_000; pub const DEFAULT_MAX_NO_PROGRESS_TIME_MS: u64 = 300_000; /// Default maximum wall time for one repository-local `CodeGraph` synchronization. pub const DEFAULT_MAX_CODEGRAPH_SYNC_WALL_TIME_MS_PER_REPO: u64 = 3_600_000; +/// Default maximum source-symbol anchors corroborated through `CodeGraph` per repository. +pub const DEFAULT_MAX_CODEGRAPH_CORROBORATION_ANCHORS_PER_REPO: i64 = 50; /// Default maximum resident memory accepted for one worker process. pub const DEFAULT_MAX_WORKER_MEMORY_BYTES: u64 = 17_179_869_184; /// Default cooperative shutdown grace period before forced termination. @@ -25,6 +29,82 @@ pub const DEFAULT_MIN_WATCH_RESCAN_INTERVAL_MS: u64 = 10_000; /// Default maximum retained historical checkpoint-cache bytes. pub const DEFAULT_MAX_CHECKPOINT_CACHE_BYTES: u64 = 10_737_418_240; +/// Effective per-repository limit for source-symbol corroboration through `CodeGraph`. +/// +/// The workspace manifest keeps `-1` as its portable unlimited sentinel, but resolved policy and +/// consumers use this type so that sentinel handling does not leak beyond the serde boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] +pub enum CodeGraphCorroborationAnchorLimit { + /// Corroborate at most this many source-symbol anchors per repository. + Bounded(NonZeroUsize), + /// Do not apply a count limit. + Unlimited, +} + +impl CodeGraphCorroborationAnchorLimit { + /// Returns the bounded limit, or `None` when corroboration is unlimited. + #[must_use] + pub const fn bounded(self) -> Option { + match self { + Self::Bounded(limit) => Some(limit), + Self::Unlimited => None, + } + } +} + +impl TryFrom for CodeGraphCorroborationAnchorLimit { + type Error = InvalidExecutionPolicy; + + fn try_from(value: i64) -> Result { + if value == -1 { + return Ok(Self::Unlimited); + } + let limit = usize::try_from(value) + .ok() + .and_then(NonZeroUsize::new) + .ok_or(InvalidExecutionPolicy::InvalidValue { + field: "maxCodeGraphCorroborationAnchorsPerRepo", + value: value.unsigned_abs(), + })?; + Ok(Self::Bounded(limit)) + } +} + +impl Serialize for CodeGraphCorroborationAnchorLimit { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let value = match self { + Self::Bounded(limit) => i64::try_from(limit.get()).map_err(|_| { + serde::ser::Error::custom("CodeGraph corroboration anchor limit exceeds i64") + })?, + Self::Unlimited => -1, + }; + serializer.serialize_i64(value) + } +} + +impl<'de> Deserialize<'de> for CodeGraphCorroborationAnchorLimit { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + i64::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +impl std::fmt::Display for CodeGraphCorroborationAnchorLimit { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Bounded(limit) => limit.fmt(formatter), + Self::Unlimited => formatter.write_str("-1"), + } + } +} + /// Optional operator-owned execution-policy overrides from the workspace manifest. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -220,6 +300,26 @@ impl ExecutionPolicy { .join(";"); stable_id("execution-policy", &canonical) } + + /// Returns a fingerprint that also includes the additive `CodeGraph` corroboration bound. + #[must_use] + pub fn fingerprint_with_codegraph_limit( + &self, + limit: CodeGraphCorroborationAnchorLimit, + ) -> String { + let mut canonical = self + .canonical_values() + .into_iter() + .map(|(name, value)| format!("{name}={value}")) + .collect::>() + .join(";"); + write!( + canonical, + ";maxCodeGraphCorroborationAnchorsPerRepo={limit}" + ) + .expect("writing to a String cannot fail"); + stable_id("execution-policy", &canonical) + } } /// Invalid operator-owned execution policy. @@ -502,6 +602,15 @@ mod tests { let policy = ExecutionPolicy::default(); assert_eq!(policy.max_scan_wall_time_ms, 21_600_000); + assert_eq!( + CodeGraphCorroborationAnchorLimit::try_from( + DEFAULT_MAX_CODEGRAPH_CORROBORATION_ANCHORS_PER_REPO + ) + .expect("default anchor limit") + .bounded() + .map(NonZeroUsize::get), + Some(50) + ); assert_eq!(policy.max_worker_memory_bytes, 17_179_869_184); assert_eq!(policy.max_checkpoint_cache_bytes, 10_737_418_240); } @@ -535,6 +644,45 @@ mod tests { )); } + #[test] + fn corroboration_anchor_limit_should_accept_positive_or_unlimited() { + let bounded = + CodeGraphCorroborationAnchorLimit::try_from(12).expect("positive anchor limit"); + let unlimited = + CodeGraphCorroborationAnchorLimit::try_from(-1).expect("unlimited anchor limit"); + + assert_eq!(bounded.bounded().map(NonZeroUsize::get), Some(12)); + assert_eq!(unlimited.bounded(), None); + assert_ne!( + ExecutionPolicy::default().fingerprint_with_codegraph_limit(bounded), + ExecutionPolicy::default().fingerprint_with_codegraph_limit(unlimited) + ); + assert_eq!( + serde_json::to_value(bounded).expect("bounded limit serializes"), + serde_json::json!(12) + ); + assert_eq!( + serde_json::to_value(unlimited).expect("unlimited limit serializes"), + serde_json::json!(-1) + ); + } + + #[test] + fn corroboration_anchor_limit_should_reject_zero_and_values_below_sentinel() { + for value in [0, -2] { + let error = CodeGraphCorroborationAnchorLimit::try_from(value) + .expect_err("invalid anchor limit"); + + assert!(matches!( + error, + InvalidExecutionPolicy::InvalidValue { + field: "maxCodeGraphCorroborationAnchorsPerRepo", + value: observed + } if observed == value.unsigned_abs() + )); + } + } + #[test] fn technically_unrepresentable_values_should_be_rejected() { let quota = ExecutionPolicy::resolve(Some(&ExecutionPolicyOverrides { diff --git a/crates/code-system-graph-core/src/extractor.rs b/crates/code-system-graph-core/src/extractor.rs index a39be63..d78e3f3 100644 --- a/crates/code-system-graph-core/src/extractor.rs +++ b/crates/code-system-graph-core/src/extractor.rs @@ -143,6 +143,7 @@ pub enum ExtractorError { } /// Focused, deterministic contract extractor. +#[allow(clippy::double_must_use)] #[async_trait] pub trait BoundaryExtractor: Send + Sync { /// Stable extractor identity. diff --git a/crates/code-system-graph-core/src/graphql_contracts.rs b/crates/code-system-graph-core/src/graphql_contracts.rs index 8c6a1eb..e653be9 100644 --- a/crates/code-system-graph-core/src/graphql_contracts.rs +++ b/crates/code-system-graph-core/src/graphql_contracts.rs @@ -3797,7 +3797,7 @@ mod tests { let document = parse_graphql_source(SourceLanguage::TypeScript, input) .expect("bounded TypeScript extraction should succeed"); - assert!(document.resolvers.is_empty()); + assert_eq!(document.resolvers, Vec::new()); } #[test] diff --git a/crates/code-system-graph-core/src/ignore_policy.rs b/crates/code-system-graph-core/src/ignore_policy.rs index eaecff2..6ad6504 100644 --- a/crates/code-system-graph-core/src/ignore_policy.rs +++ b/crates/code-system-graph-core/src/ignore_policy.rs @@ -3,6 +3,7 @@ use std::path::{Component, Path, PathBuf}; use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; +use ignore::{IncrementalIgnore, WalkBuilder}; use serde::ser::{Serialize, SerializeStruct, Serializer}; use thiserror::Error; @@ -94,6 +95,8 @@ pub struct IgnorePolicy { configured_excludes_source: ConfigSource, include_defaults: Vec, include_defaults_source: ConfigSource, + use_gitignore: bool, + use_gitignore_source: ConfigSource, protected_matcher: GlobSet, default_matcher: GlobSet, configured_matcher: GlobSet, @@ -108,6 +111,8 @@ impl PartialEq for IgnorePolicy { && self.configured_excludes_source == other.configured_excludes_source && self.include_defaults == other.include_defaults && self.include_defaults_source == other.include_defaults_source + && self.use_gitignore == other.use_gitignore + && self.use_gitignore_source == other.use_gitignore_source } } @@ -118,7 +123,7 @@ impl Serialize for IgnorePolicy { where S: Serializer, { - let mut state = serializer.serialize_struct("IgnorePolicy", 4)?; + let mut state = serializer.serialize_struct("IgnorePolicy", 6)?; state.serialize_field("configured_excludes", &self.configured_excludes)?; state.serialize_field( "configured_excludes_source", @@ -126,6 +131,8 @@ impl Serialize for IgnorePolicy { )?; state.serialize_field("include_defaults", &self.include_defaults)?; state.serialize_field("include_defaults_source", &self.include_defaults_source)?; + state.serialize_field("use_gitignore", &self.use_gitignore)?; + state.serialize_field("use_gitignore_source", &self.use_gitignore_source)?; state.end() } } @@ -142,6 +149,30 @@ impl IgnorePolicy { configured_excludes_source: ConfigSource, include_defaults: Vec, include_defaults_source: ConfigSource, + ) -> Result { + Self::with_gitignore( + configured_excludes, + configured_excludes_source, + include_defaults, + include_defaults_source, + false, + ConfigSource::Default, + ) + } + + /// Builds one validated, compiled repository exclusion policy with optional `.gitignore` + /// discovery. + /// + /// # Errors + /// + /// Returns [`IgnorePatternError`] when a configured pattern is unsafe or malformed. + pub fn with_gitignore( + configured_excludes: Vec, + configured_excludes_source: ConfigSource, + include_defaults: Vec, + include_defaults_source: ConfigSource, + use_gitignore: bool, + use_gitignore_source: ConfigSource, ) -> Result { let mut configured_excludes = normalize_patterns(configured_excludes)?; let mut include_defaults = normalize_patterns(include_defaults)?; @@ -160,6 +191,8 @@ impl IgnorePolicy { configured_excludes_source, include_defaults, include_defaults_source, + use_gitignore, + use_gitignore_source, include_prefixes, include_can_match_anywhere, }) @@ -189,6 +222,18 @@ impl IgnorePolicy { self.include_defaults_source } + /// Whether repository-contained `.gitignore` files participate in automatic discovery. + #[must_use] + pub const fn use_gitignore(&self) -> bool { + self.use_gitignore + } + + /// Configuration layer that selected [`Self::use_gitignore`]. + #[must_use] + pub const fn use_gitignore_source(&self) -> ConfigSource { + self.use_gitignore_source + } + /// Whether one repository-relative path must be omitted from automatic discovery. #[must_use] pub fn excludes(&self, relative: &Path, directory: bool) -> bool { @@ -213,11 +258,14 @@ impl IgnorePolicy { pub fn fingerprint_material(&self) -> String { format!( "version={IGNORE_POLICY_VERSION};protected={PROTECTED_EXCLUDES:?};defaults={DEFAULT_EXCLUDES:?};\ - excludes={:?};excludes_source={:?};include_defaults={:?};include_defaults_source={:?}", + excludes={:?};excludes_source={:?};include_defaults={:?};include_defaults_source={:?};\ + use_gitignore={};use_gitignore_source={:?}", self.configured_excludes, self.configured_excludes_source, self.include_defaults, - self.include_defaults_source + self.include_defaults_source, + self.use_gitignore, + self.use_gitignore_source ) } @@ -231,6 +279,169 @@ impl IgnorePolicy { } } +/// Failure while applying repository-contained `.gitignore` files during discovery. +#[derive(Debug, Error)] +pub enum RepositoryDiscoveryError { + /// Directory traversal or an enabled ignore file failed. + #[error("repository discovery failed under `{root}`: {source}")] + Ignore { + /// Registered repository root. + root: PathBuf, + /// Bounded walker or ignore-file failure. + #[source] + source: ignore::Error, + }, + /// A walker result unexpectedly escaped its configured root. + #[error("repository discovery path `{path}` escaped root `{root}`")] + OutsideRoot { + /// Registered repository root. + root: PathBuf, + /// Unexpected walker path. + path: PathBuf, + }, +} + +/// Cached matcher for event paths outside a complete repository traversal. +#[derive(Debug, Clone)] +pub struct RepositoryPathMatcher { + policy: IgnorePolicy, + gitignore: Option, +} + +impl RepositoryPathMatcher { + /// Builds a matcher rooted at one registered checkout. + #[must_use] + pub fn new(root: &Path, policy: IgnorePolicy) -> Self { + let gitignore = policy + .use_gitignore() + .then(|| { + let mut matchers = gitignore_walk_builder(root, true).build_matchers(); + matchers.pop() + }) + .flatten(); + Self { policy, gitignore } + } + + /// Returns whether a path is excluded by protected, configured, default, or Git rules. + /// + /// # Errors + /// + /// Returns [`RepositoryDiscoveryError`] when an enabled ignore file cannot be interpreted. + pub fn excludes( + &mut self, + relative: &Path, + directory: bool, + ) -> Result { + if self.policy.excludes(relative, directory) { + return Ok(true); + } + let Some(matcher) = self.gitignore.as_mut() else { + return Ok(false); + }; + let (match_result, error) = matcher.matched_with_errors(relative, directory); + if let Some(source) = error { + return Err(RepositoryDiscoveryError::Ignore { + root: matcher.root().to_path_buf(), + source, + }); + } + Ok(match_result.is_ignore()) + } +} + +/// Discovers regular, non-symlink files under one repository with the complete native policy. +/// +/// `max_depth` uses walker depth, where the configured repository root is depth zero. +/// +/// # Errors +/// +/// Returns [`RepositoryDiscoveryError`] for directory or enabled ignore-file failures. +pub fn discover_repository_files( + root: &Path, + policy: &IgnorePolicy, + max_depth: Option, +) -> Result, RepositoryDiscoveryError> { + discover_repository_files_matching(root, policy, max_depth, None, |_| true) +} + +pub(crate) fn discover_repository_files_matching( + root: &Path, + policy: &IgnorePolicy, + max_depth: Option, + max_results: Option, + mut matches: impl FnMut(&Path) -> bool, +) -> Result, RepositoryDiscoveryError> { + let mut builder = gitignore_walk_builder(root, policy.use_gitignore()); + if let Some(max_depth) = max_depth { + builder.max_depth(Some(max_depth)); + } + let filter_policy = policy.clone(); + let filter_root = root.to_path_buf(); + builder.filter_entry(move |entry| { + if entry.depth() == 0 { + return true; + } + let Some(file_type) = entry.file_type() else { + return false; + }; + if file_type.is_symlink() { + return false; + } + entry + .path() + .strip_prefix(&filter_root) + .is_ok_and(|relative| !filter_policy.excludes(relative, file_type.is_dir())) + }); + builder.sort_by_file_path(std::path::Path::cmp); + + let mut files = Vec::new(); + for entry in builder.build() { + let entry = entry.map_err(|source| RepositoryDiscoveryError::Ignore { + root: root.to_path_buf(), + source, + })?; + let Some(file_type) = entry.file_type() else { + continue; + }; + if entry.depth() == 0 || !file_type.is_file() || file_type.is_symlink() { + continue; + } + let relative = + entry + .path() + .strip_prefix(root) + .map_err(|_| RepositoryDiscoveryError::OutsideRoot { + root: root.to_path_buf(), + path: entry.path().to_path_buf(), + })?; + if !matches(relative) { + continue; + } + files.push(relative.to_path_buf()); + if max_results.is_some_and(|limit| files.len() >= limit) { + break; + } + } + files.sort(); + files.dedup(); + Ok(files) +} + +fn gitignore_walk_builder(root: &Path, use_gitignore: bool) -> WalkBuilder { + let mut builder = WalkBuilder::new(root); + builder + .standard_filters(false) + .hidden(false) + .parents(false) + .ignore(false) + .git_ignore(use_gitignore) + .git_global(false) + .git_exclude(false) + .require_git(false) + .follow_links(false); + builder +} + /// Validates configured exclusion globs. /// /// # Errors @@ -407,8 +618,39 @@ fn unsafe_character(character: char) -> bool { #[cfg(test)] mod tests { + use std::fs; + + use tempfile::tempdir; + use super::*; + #[test] + fn bounded_matching_discovery_should_stop_after_the_requested_results() + -> Result<(), Box> { + let repository = tempdir()?; + for index in 0..40 { + fs::write( + repository.path().join(format!("openapi-{index:02}.yaml")), + "openapi: 3.1.0\n", + )?; + } + let mut visited = 0_usize; + let files = discover_repository_files_matching( + repository.path(), + &policy(&[], &[]), + None, + Some(32), + |_| { + visited += 1; + true + }, + )?; + + assert_eq!(files.len(), 32); + assert_eq!(visited, 32); + Ok(()) + } + fn policy(excludes: &[&str], includes: &[&str]) -> IgnorePolicy { IgnorePolicy::new( excludes.iter().map(ToString::to_string).collect(), @@ -419,6 +661,129 @@ mod tests { .expect("valid fixture policy") } + fn gitignore_policy(excludes: &[&str], includes: &[&str]) -> IgnorePolicy { + IgnorePolicy::with_gitignore( + excludes.iter().map(ToString::to_string).collect(), + ConfigSource::WorkspaceManifest, + includes.iter().map(ToString::to_string).collect(), + ConfigSource::WorkspaceManifest, + true, + ConfigSource::WorkspaceManifest, + ) + .expect("valid fixture policy") + } + + fn write(root: &Path, relative: &str, contents: &str) { + let path = root.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create fixture parent"); + } + fs::write(path, contents).expect("write fixture"); + } + + #[test] + fn gitignore_should_be_opt_in_and_support_nested_rules_and_negation() { + let checkout = tempdir().expect("checkout"); + let root = checkout.path(); + write( + root, + ".gitignore", + "ignored/*\n!ignored/keep.rs\nspace\\ path.rs\n", + ); + write(root, "ignored/drop.rs", "drop"); + write(root, "ignored/keep.rs", "keep"); + write(root, "space path.rs", "space"); + write(root, "nested/.gitignore", "*.rs\n!keep.rs\n"); + write(root, "nested/drop.rs", "drop"); + write(root, "nested/keep.rs", "keep"); + + let enabled = discover_repository_files(root, &gitignore_policy(&[], &[]), None) + .expect("enabled discovery"); + assert!(enabled.contains(&PathBuf::from("ignored/keep.rs"))); + assert!(enabled.contains(&PathBuf::from("nested/keep.rs"))); + assert!(!enabled.contains(&PathBuf::from("ignored/drop.rs"))); + assert!(!enabled.contains(&PathBuf::from("nested/drop.rs"))); + assert!(!enabled.contains(&PathBuf::from("space path.rs"))); + + let disabled = + discover_repository_files(root, &policy(&[], &[]), None).expect("disabled discovery"); + assert!(disabled.contains(&PathBuf::from("ignored/drop.rs"))); + assert!(disabled.contains(&PathBuf::from("nested/drop.rs"))); + assert!(disabled.contains(&PathBuf::from("space path.rs"))); + } + + #[test] + fn gitignore_should_not_read_parent_dot_ignore_or_git_exclude_rules() { + let parent = tempdir().expect("parent"); + let root = parent.path().join("checkout"); + fs::create_dir_all(root.join(".git/info")).expect("git metadata"); + write(parent.path(), ".gitignore", "from-parent.rs\n"); + write(&root, ".ignore", "from-dot-ignore.rs\n"); + write(&root, ".git/info/exclude", "from-git-exclude.rs\n"); + for file in [ + "from-parent.rs", + "from-dot-ignore.rs", + "from-git-exclude.rs", + ] { + write(&root, file, file); + } + + let files = + discover_repository_files(&root, &gitignore_policy(&[], &[]), None).expect("discovery"); + for file in [ + "from-parent.rs", + "from-dot-ignore.rs", + "from-git-exclude.rs", + ] { + assert!(files.contains(&PathBuf::from(file)), "missing {file}"); + } + } + + #[test] + fn explicit_and_protected_exclusions_should_override_gitignore_negations() { + let checkout = tempdir().expect("checkout"); + let root = checkout.path(); + write( + root, + ".gitignore", + "!generated/private.rs\n!vendor/sdk/lib.rs\n!.git/config\n", + ); + write(root, "generated/private.rs", "private"); + write(root, "vendor/sdk/lib.rs", "sdk"); + write(root, ".git/config", "config"); + + let files = discover_repository_files( + root, + &gitignore_policy(&["generated/**"], &["vendor/sdk/**"]), + None, + ) + .expect("discovery"); + assert!(!files.contains(&PathBuf::from("generated/private.rs"))); + assert!(files.contains(&PathBuf::from("vendor/sdk/lib.rs"))); + assert!(!files.contains(&PathBuf::from(".git/config"))); + } + + #[test] + fn path_matcher_should_follow_nested_gitignore_rules() { + let checkout = tempdir().expect("checkout"); + let root = checkout.path(); + write(root, ".gitignore", "root.rs\n"); + write(root, "nested/.gitignore", "*.rs\n!keep.rs\n"); + let mut matcher = RepositoryPathMatcher::new(root, gitignore_policy(&[], &[])); + + assert!(matcher.excludes(Path::new("root.rs"), false).expect("root")); + assert!( + matcher + .excludes(Path::new("nested/drop.rs"), false) + .expect("nested drop") + ); + assert!( + !matcher + .excludes(Path::new("nested/keep.rs"), false) + .expect("nested keep") + ); + } + #[test] fn defaults_should_exclude_nested_dependency_tree() { assert!(policy(&[], &[]).excludes(Path::new("web/node_modules/react/index.js"), false)); diff --git a/crates/code-system-graph-core/src/infrastructure.rs b/crates/code-system-graph-core/src/infrastructure.rs index 506bd53..6dee2d9 100644 --- a/crates/code-system-graph-core/src/infrastructure.rs +++ b/crates/code-system-graph-core/src/infrastructure.rs @@ -2051,7 +2051,10 @@ spec: let document = extract_kubernetes("objects.yaml", input).expect("valid Kubernetes"); - assert!(document.deployment_units[0].service_names.is_empty()); + assert_eq!( + document.deployment_units[0].service_names, + Vec::::new() + ); } #[test] diff --git a/crates/code-system-graph-core/src/lib.rs b/crates/code-system-graph-core/src/lib.rs index e220ed1..18adec0 100644 --- a/crates/code-system-graph-core/src/lib.rs +++ b/crates/code-system-graph-core/src/lib.rs @@ -67,7 +67,7 @@ pub use communities::{ CommunityError, analyze_communities, analyze_communities_with_progress, compare_community_snapshots }; pub use config::{ - ConfigError, ConfigSource, EffectiveRepositoryConfig, apply_openapi_override, resolve_repository_config + ConfigError, ConfigSource, EffectiveRepositoryConfig, apply_openapi_override, resolve_repository_config, resolve_repository_config_with_use_gitignore }; pub use contract_compat::{ CompatibilityFinding, CompatibilityReport, CompatibilityStatus, compare_database_contracts, compare_event_contracts, compare_graphql_contracts, compare_http_contracts, compare_package_contracts, compare_protobuf_contracts @@ -86,7 +86,7 @@ pub use events::{ DeliverySemantics, EventBroker, EventDocument, EventEvidenceLine, EventExtractionError, EventObservation, EventRole, EventSchemaDefinition, EventSchemaField, extract_asyncapi, parse_event_source }; pub use execution_policy::{ - ExecutionLimitExceeded, ExecutionPolicy, ExecutionPolicyOverrides, ExecutionResource, ExecutionSummary, InvalidExecutionPolicy, JobPhase, MonotonicClock, ScanJobTracker + CodeGraphCorroborationAnchorLimit, DEFAULT_MAX_CODEGRAPH_CORROBORATION_ANCHORS_PER_REPO, ExecutionLimitExceeded, ExecutionPolicy, ExecutionPolicyOverrides, ExecutionResource, ExecutionSummary, InvalidExecutionPolicy, JobPhase, MonotonicClock, ScanJobTracker }; pub use extraction_budget::{ BoundedJsonWriter, EXTRACTION_CONTRACT_VERSION, ExtractionBudgetOverrides, ExtractionBudgets, ExtractionClock, ExtractionLimitExceeded, ExtractionResource, ExtractionTracker, InvalidExtractionBudget @@ -106,7 +106,7 @@ pub use http::{ BoundaryRole, HttpBoundary, HttpExtractionError, extract_openapi, extract_openapi_with_tracker, normalize_http_path }; pub use ignore_policy::{ - DEFAULT_EXCLUDES, IGNORE_POLICY_VERSION, IgnorePatternError, IgnorePolicy, PROTECTED_EXCLUDES, validate_excludes, validate_include_defaults + DEFAULT_EXCLUDES, IGNORE_POLICY_VERSION, IgnorePatternError, IgnorePolicy, PROTECTED_EXCLUDES, RepositoryDiscoveryError, RepositoryPathMatcher, discover_repository_files, validate_excludes, validate_include_defaults }; pub use impact::{ CompatibilityInput, ContractImpact, CoverageSummary, CriticalityAssignment, CriticalityTag, EnvironmentAssignment, ImpactClassification, ImpactCompatibilityStatus, ImpactContext, ImpactDepthBucket, ImpactDirection, ImpactError, ImpactItem, ImpactOptions, ImpactPathStep, ImpactReport, ImpactRequest, ImpactTarget, LocalEnrichmentInput, LocalEnrichmentStatus, LocalImpactItem, LocalImpactSummary, RecommendedCommand, RepositoryImpact, ResolvedTarget, RiskFactor, RiskLevel, ServiceImpact, TestRecommendation, TestRecommendationSource, TruncationInfo, analyze_impact @@ -122,7 +122,7 @@ pub use linker::{ HttpLinkAmbiguity, HttpLinkResolution, LinkError, ManualLinkEndpoint, ManualLinkError, ManualLinkResolution, link_http_boundaries, link_http_boundaries_with_ambiguities, merge_affected_link_neighborhoods, resolve_manual_links }; pub use manifest::{ - ContractImplementationConfig, HttpConsumerConfig, HttpContractConfig, IntegrationTestConfig, ManifestError, ManualLinkConfig, RepositoryConfig, WorkspaceManifest, parse_manifest, validate_manual_links + ContractImplementationConfig, HttpConsumerConfig, HttpContractConfig, IntegrationTestConfig, ManifestError, ManifestExtensions, ManualLinkConfig, RepositoryConfig, WorkspaceManifest, parse_manifest, parse_manifest_with_extensions, validate_manual_links }; pub use manifest_edit::{ ManifestEdit, ManifestEditError, ManifestWriteReport, commit_manifest_edit, preview_add_manual_link, preview_add_repository, preview_remove_repository diff --git a/crates/code-system-graph-core/src/linker.rs b/crates/code-system-graph-core/src/linker.rs index ea41f02..856df73 100644 --- a/crates/code-system-graph-core/src/linker.rs +++ b/crates/code-system-graph-core/src/linker.rs @@ -651,7 +651,7 @@ paths: provider("repo:api-b"), ]); - assert!(result.edges.is_empty()); + assert_eq!(result.edges, Vec::new()); assert_eq!(result.ambiguities.len(), 1); assert_eq!(result.ambiguities[0].method, "POST"); assert_eq!(result.ambiguities[0].path, "/api/orders"); diff --git a/crates/code-system-graph-core/src/manifest.rs b/crates/code-system-graph-core/src/manifest.rs index 27c4d1b..d175701 100644 --- a/crates/code-system-graph-core/src/manifest.rs +++ b/crates/code-system-graph-core/src/manifest.rs @@ -5,7 +5,9 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::execution_policy::{ExecutionPolicy, ExecutionPolicyOverrides, InvalidExecutionPolicy}; +use crate::execution_policy::{ + CodeGraphCorroborationAnchorLimit, ExecutionPolicy, ExecutionPolicyOverrides, InvalidExecutionPolicy +}; use crate::extraction_budget::{ ExtractionBudgetOverrides, ExtractionBudgets, InvalidExtractionBudget }; @@ -59,6 +61,74 @@ pub struct RepositoryConfig { pub include_defaults: Option>, } +/// Additive manifest settings introduced without changing exhaustively constructible public +/// configuration structs. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct ManifestExtensions { + repository_use_gitignore: BTreeMap, + max_codegraph_corroboration_anchors_per_repo: Option, +} + +impl ManifestExtensions { + /// Returns the workspace-level `.gitignore` choice for one repository alias. + #[must_use] + pub fn repository_use_gitignore(&self, alias: &str) -> Option { + self.repository_use_gitignore.get(alias).copied() + } + + /// Returns the configured `CodeGraph` corroboration bound, including `-1` for unlimited. + #[must_use] + pub const fn max_codegraph_corroboration_anchors_per_repo(&self) -> Option { + self.max_codegraph_corroboration_anchors_per_repo + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct WorkspaceManifestWire { + version: u32, + name: String, + #[serde(rename = "allowedRoots", default)] + allowed_roots: Vec, + repos: BTreeMap, + #[serde(rename = "manualLinks", default)] + manual_links: Vec, + #[serde(rename = "extractionBudgets", default)] + extraction_budgets: Option, + #[serde(rename = "executionPolicy", default)] + execution_policy: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RepositoryConfigWire { + path: String, + openapi: Option, + http_consumers: Option>, + integration_tests: Option>, + implementations: Option>, + excludes: Option>, + include_defaults: Option>, + use_gitignore: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExecutionPolicyOverridesWire { + max_scan_wall_time_ms: Option, + max_no_progress_time_ms: Option, + #[serde(rename = "maxCodeGraphSyncWallTimeMsPerRepo")] + max_codegraph_sync_wall_time_ms_per_repo: Option, + #[serde(rename = "maxCodeGraphCorroborationAnchorsPerRepo")] + max_codegraph_corroboration_anchors_per_repo: Option, + max_worker_memory_bytes: Option, + graceful_termination_ms: Option, + watch_idle_timeout_ms: Option, + max_watch_session_wall_time_ms: Option, + min_watch_rescan_interval_ms: Option, + max_checkpoint_cache_bytes: Option, +} + /// Exact manual relationship or automatic-link suppression. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] @@ -219,7 +289,88 @@ pub enum ManifestError { /// Returns [`ManifestError`] for malformed YAML, unknown keys, unsupported versions, empty /// fields, or an empty repository registry. pub fn parse_manifest(input: &str) -> Result { - let manifest: WorkspaceManifest = crate::yaml::from_str(input)?; + parse_manifest_with_extensions(input).map(|(manifest, _)| manifest) +} + +/// Parses a strict workspace manifest together with additive patch-compatible settings. +/// +/// # Errors +/// +/// Returns [`ManifestError`] under the same conditions as [`parse_manifest`]. +pub fn parse_manifest_with_extensions( + input: &str, +) -> Result<(WorkspaceManifest, ManifestExtensions), ManifestError> { + let wire: WorkspaceManifestWire = crate::yaml::from_str(input)?; + let mut repository_use_gitignore = BTreeMap::new(); + let repos = wire + .repos + .into_iter() + .map(|(alias, repository)| { + if let Some(value) = repository.use_gitignore { + repository_use_gitignore.insert(alias.clone(), value); + } + ( + alias, + RepositoryConfig { + path: repository.path, + openapi: repository.openapi, + http_consumers: repository.http_consumers, + integration_tests: repository.integration_tests, + implementations: repository.implementations, + excludes: repository.excludes, + include_defaults: repository.include_defaults, + }, + ) + }) + .collect(); + let (execution_policy, max_codegraph_corroboration_anchors_per_repo) = wire + .execution_policy + .map(ExecutionPolicyOverridesWire::into_parts) + .map_or((None, None), |(policy, limit)| (Some(policy), limit)); + if let Some(value) = max_codegraph_corroboration_anchors_per_repo { + CodeGraphCorroborationAnchorLimit::try_from(value)?; + } + let manifest = WorkspaceManifest { + version: wire.version, + name: wire.name, + allowed_roots: wire.allowed_roots, + repos, + manual_links: wire.manual_links, + extraction_budgets: wire.extraction_budgets, + execution_policy, + }; + validate_manifest(manifest).map(|manifest| { + ( + manifest, + ManifestExtensions { + repository_use_gitignore, + max_codegraph_corroboration_anchors_per_repo, + }, + ) + }) +} + +impl ExecutionPolicyOverridesWire { + fn into_parts(self) -> (ExecutionPolicyOverrides, Option) { + ( + ExecutionPolicyOverrides { + max_scan_wall_time_ms: self.max_scan_wall_time_ms, + max_no_progress_time_ms: self.max_no_progress_time_ms, + max_codegraph_sync_wall_time_ms_per_repo: self + .max_codegraph_sync_wall_time_ms_per_repo, + max_worker_memory_bytes: self.max_worker_memory_bytes, + graceful_termination_ms: self.graceful_termination_ms, + watch_idle_timeout_ms: self.watch_idle_timeout_ms, + max_watch_session_wall_time_ms: self.max_watch_session_wall_time_ms, + min_watch_rescan_interval_ms: self.min_watch_rescan_interval_ms, + max_checkpoint_cache_bytes: self.max_checkpoint_cache_bytes, + }, + self.max_codegraph_corroboration_anchors_per_repo, + ) + } +} + +fn validate_manifest(manifest: WorkspaceManifest) -> Result { if manifest.version != 1 { return Err(ManifestError::UnsupportedVersion { found: manifest.version, @@ -399,7 +550,9 @@ fn validate_maximum(field: &str, value: &str, maximum: usize) -> Result<(), Mani mod tests { use code_system_graph_model::EdgeKind; - use super::{MANUAL_REASON_MAX_BYTES, ManifestError, parse_manifest}; + use super::{ + MANUAL_REASON_MAX_BYTES, ManifestError, parse_manifest, parse_manifest_with_extensions + }; use crate::{ExecutionPolicy, ExtractionBudgets, IgnorePatternError}; const VALID: &str = r" @@ -543,6 +696,10 @@ repos: "name: commerce", "name: commerce\nexecutionPolicy:\n maxScanWallTimeMs: 1000\n maxNoProgressTimeMs: 1001\n maxCodeGraphSyncWallTimeMsPerRepo: 1000\n gracefulTerminationMs: 1", ); + let invalid_anchor_limit = VALID.replace( + "name: commerce", + "name: commerce\nexecutionPolicy:\n maxCodeGraphCorroborationAnchorsPerRepo: -2", + ); assert!(matches!( parse_manifest(&zero), @@ -556,6 +713,10 @@ repos: parse_manifest(&unknown), Err(ManifestError::InvalidYaml(_)) )); + assert!(matches!( + parse_manifest(&invalid_anchor_limit), + Err(ManifestError::InvalidExecutionPolicy(_)) + )); let invalid_result = parse_manifest(&invalid); assert!( matches!( @@ -578,6 +739,28 @@ repos: assert!(result.is_ok(), "unexpected manifest error: {result:?}"); } + #[test] + fn parse_manifest_should_keep_additive_settings_out_of_public_structs() { + let input = VALID.replace( + "name: commerce", + "name: commerce\nexecutionPolicy:\n maxCodeGraphCorroborationAnchorsPerRepo: 12", + ); + let input = input.replace( + " path: ../web", + " path: ../web\n useGitignore: true", + ); + + let (manifest, extensions) = + parse_manifest_with_extensions(&input).expect("additive settings are valid"); + + assert!(manifest.execution_policy.is_some()); + assert_eq!(extensions.repository_use_gitignore("web"), Some(true)); + assert_eq!( + extensions.max_codegraph_corroboration_anchors_per_repo(), + Some(12) + ); + } + #[test] fn parse_manifest_should_reject_protected_default_include() { let input = VALID.replace( diff --git a/crates/code-system-graph-core/src/protobuf_contracts.rs b/crates/code-system-graph-core/src/protobuf_contracts.rs index 76f7119..4905f88 100644 --- a/crates/code-system-graph-core/src/protobuf_contracts.rs +++ b/crates/code-system-graph-core/src/protobuf_contracts.rs @@ -1732,7 +1732,7 @@ const invalid = "/example.v1.Greeter/Chat/extra" "channel.unary_unary('/example.v1.Greeter/Get')", ); - assert!(markers.is_empty()); + assert_eq!(markers, Vec::new()); } #[test] @@ -1743,6 +1743,6 @@ const invalid = "/example.v1.Greeter/Chat/extra" "// Generated by the protocol buffer compiler. DO NOT EDIT!\n// \"/example.Greeter/Get\"\n", ); - assert!(markers.is_empty()); + assert_eq!(markers, Vec::new()); } } diff --git a/crates/code-system-graph-core/src/provider.rs b/crates/code-system-graph-core/src/provider.rs index 89a9ddd..efc88af 100644 --- a/crates/code-system-graph-core/src/provider.rs +++ b/crates/code-system-graph-core/src/provider.rs @@ -347,6 +347,7 @@ pub enum ProviderError { } /// Optional boundary for repository-local code intelligence. +#[allow(clippy::double_must_use)] #[async_trait] pub trait LocalCodeIntelligenceProvider: Send + Sync { /// Returns the stable provider name. diff --git a/crates/code-system-graph-core/src/pull_requests.rs b/crates/code-system-graph-core/src/pull_requests.rs index 609581e..6687e44 100644 --- a/crates/code-system-graph-core/src/pull_requests.rs +++ b/crates/code-system-graph-core/src/pull_requests.rs @@ -623,6 +623,7 @@ pub struct PrHttpTransportError { } /// Injectable asynchronous HTTP boundary. +#[allow(clippy::double_must_use)] #[async_trait] pub trait PrHttpTransport: Send + Sync { /// Sends one bounded request. @@ -797,6 +798,7 @@ pub enum PullRequestError { } /// Asynchronous provider interface. +#[allow(clippy::double_must_use)] #[async_trait] pub trait PullRequestProvider: Send + Sync { /// Returns the provider contract implemented by this client. diff --git a/crates/code-system-graph-core/src/query.rs b/crates/code-system-graph-core/src/query.rs index b2072d1..304b67e 100644 --- a/crates/code-system-graph-core/src/query.rs +++ b/crates/code-system-graph-core/src/query.rs @@ -1707,7 +1707,7 @@ mod tests { request.filters.edge_kinds = vec![EdgeKind::Validates, EdgeKind::CallsRemote]; let report = report_or_panic(traverse(&nodes, &edges, &request)); - assert!(report.paths.is_empty()); + assert_eq!(report.paths, Vec::new()); } #[test] diff --git a/crates/code-system-graph-core/src/registry.rs b/crates/code-system-graph-core/src/registry.rs index edc1648..8770802 100644 --- a/crates/code-system-graph-core/src/registry.rs +++ b/crates/code-system-graph-core/src/registry.rs @@ -414,6 +414,7 @@ mod tests { let linked = temporary.path().join("linked"); fs::create_dir_all(&repository)?; git(&repository, &["init"])?; + git(&repository, &["config", "core.autocrlf", "false"])?; fs::write(repository.join("README.md"), "fixture")?; git(&repository, &["add", "README.md"])?; git( diff --git a/crates/code-system-graph-core/src/source_http.rs b/crates/code-system-graph-core/src/source_http.rs index dcf7faa..55c0c41 100644 --- a/crates/code-system-graph-core/src/source_http.rs +++ b/crates/code-system-graph-core/src/source_http.rs @@ -2719,7 +2719,7 @@ fn ordinary() { "##; let result = parse_rust_source(source); - assert!(result.is_empty()); + assert_eq!(result, Vec::new()); } #[test] @@ -3050,7 +3050,7 @@ client.get("/not-httpx") "#; let result = parse_python_source(source); - assert!(result.is_empty()); + assert_eq!(result, Vec::new()); } #[test] diff --git a/crates/code-system-graph-core/tests/source_compatibility.rs b/crates/code-system-graph-core/tests/source_compatibility.rs new file mode 100644 index 0000000..134fca6 --- /dev/null +++ b/crates/code-system-graph-core/tests/source_compatibility.rs @@ -0,0 +1,42 @@ +//! Compile-time guards for exhaustively constructible public structs published in 1.0.2. + +use code_system_graph_core::{ExecutionPolicy, ExecutionPolicyOverrides, RepositoryConfig}; + +#[test] +fn version_1_0_2_public_struct_literals_should_still_compile() { + let repository = RepositoryConfig { + path: "../service".to_owned(), + openapi: None, + http_consumers: None, + integration_tests: None, + implementations: None, + excludes: None, + include_defaults: None, + }; + let overrides = ExecutionPolicyOverrides { + max_scan_wall_time_ms: None, + max_no_progress_time_ms: None, + max_codegraph_sync_wall_time_ms_per_repo: None, + max_worker_memory_bytes: None, + graceful_termination_ms: None, + watch_idle_timeout_ms: None, + max_watch_session_wall_time_ms: None, + min_watch_rescan_interval_ms: None, + max_checkpoint_cache_bytes: None, + }; + let policy = ExecutionPolicy { + max_scan_wall_time_ms: 1, + max_no_progress_time_ms: 1, + max_codegraph_sync_wall_time_ms_per_repo: 1, + max_worker_memory_bytes: 1, + graceful_termination_ms: 1, + watch_idle_timeout_ms: 1, + max_watch_session_wall_time_ms: 1, + min_watch_rescan_interval_ms: 1, + max_checkpoint_cache_bytes: 1, + }; + + assert_eq!(repository.path, "../service"); + assert!(overrides.max_scan_wall_time_ms.is_none()); + assert_eq!(policy.max_scan_wall_time_ms, 1); +} diff --git a/crates/code-system-graph-hooks/agent-integration-template/README.md b/crates/code-system-graph-hooks/agent-integration-template/README.md new file mode 100644 index 0000000..8c90ab0 --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/README.md @@ -0,0 +1,54 @@ +# Agent integration templates + +This directory is the single source of truth for editable agent installation, skill, discovery, +and activation content. Rust code may select, validate, merge, quote, and render these files, but +must not duplicate their editable prose, classifier vocabulary, or shell bodies. MCP tool schemas, +runtime results, validation errors, and protocol adapters remain next to the Rust API they define. + +## Layout + +- `agent-plugin/` contains the Agent Plugins 1.0 portable package. Its + `skills/code-system-graph/SKILL.md` is the only detailed MCP operating procedure. Its + `metadata/` directory owns discovery and optional client-facing labels rendered into that skill. +- `native-hooks/` contains optional activation adapters: prompt signals, short selector guidance, + static fallback rules, strict-gate shell, host limitations, and UI text. These files may select + the canonical skill but must not copy its detailed workflow. + +Generated plugin repositories such as `hugint-agent-plugin` contain managed rendered copies, not +another source. After changing this directory, rebuild `csgraph` and `code-system-graph-hooks`. +When an existing rendered skill changes, run `csgraph plugin uninstall` for that managed +integration and then run the plugin installer to render it again. `--replace-generated` updates +owned local binding state; it does not overwrite a different versioned skill. + +## Placeholder contracts + +Agent Plugin templates are rendered by the strict CLI renderer. Unknown, missing, duplicated, or +unused `{{NAME}}` placeholders fail generation. + +- `agent-plugin/plugin.json`: `PLUGIN_NAME`, `PLUGIN_VERSION`, `WORKSPACE_JSON_STRING`; +- `agent-plugin/mcp.json`: `MCP_SERVER_NAME`; +- `agent-plugin/skills/code-system-graph/SKILL.md`: `SKILL_NAME`, + `SKILL_DESCRIPTION_YAML`, `WORKSPACE`; +- `agent-plugin/skills/code-system-graph/agents/openai.yaml`: `SKILL_DISPLAY_NAME_YAML`, + `SKILL_DEFAULT_PROMPT_YAML`; +- `agent-plugin/metadata/skill-description.txt`: `WORKSPACE`; +- `agent-plugin/metadata/openai-display-name.txt`: `WORKSPACE`; +- `agent-plugin/metadata/openai-default-prompt.txt`: `SKILL_NAME`. + +Native templates use these exact placeholders: + +- `static-rule.md`: `BEGIN_MARKER`, `ROUTING`, `END_MARKER`; +- `cursor-rule.mdc`: `PRODUCT_MARKER`, `BLOCK`; +- `strict-gate.sh`: `BEGIN_MARKER`, `BINARY`, `DATABASE`, `WORKSPACE`, `REPOSITORY`, `END_MARKER`. + +Dynamic guidance, signal lists, limitation text, and metadata contain no placeholders. Run +`cargo test -p code-system-graph-hooks --all-features --locked` and the Agent Plugin E2E suite +after editing this tree. + +Signal matching is deterministic: the prompt is lowercased and every nonempty signal line is +matched as a literal substring, not by a semantic or language-independent classifier. Write signal +lines in lowercase, keep phrases specific enough to express graph intent, include accented and +unaccented variants when needed, and add positive and negative tests for each supported language. +The bundled lists cover high-precision English and Spanish phrases; other languages may not +activate a dynamic hook. Avoid broad fragments such as `where is`, `dónde`, `code`, or `test` even +in a programming-only host. diff --git a/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/LICENSE b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/LICENSE new file mode 100644 index 0000000..22b25d5 --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/LICENSE @@ -0,0 +1,188 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work. + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. Derivative Works + shall not include works that remain separable from, or merely link + (or bind by name) to the interfaces of, the Work and Derivative Works. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works, that is intentionally submitted + to Licensor for inclusion in the Work by the copyright owner or by + an individual or Legal Entity authorized to submit on behalf of + the copyright owner. "Submitted" means any form of electronic, + verbal, or written communication sent to the Licensor or its + representatives, including but not limited to communication on + electronic mailing lists, source code control systems, and issue + tracking systems that are managed by, or on behalf of, the Licensor + for the purpose of discussing and improving the Work, but excluding + communication that is conspicuously marked or otherwise designated + in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Code System Graph contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/generated.gitignore b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/generated.gitignore new file mode 100644 index 0000000..1f59062 --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/generated.gitignore @@ -0,0 +1 @@ +/.local/ diff --git a/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/mcp.json b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/mcp.json new file mode 100644 index 0000000..4a9c535 --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/mcp.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "{{MCP_SERVER_NAME}}": { + "type": "stdio", + "command": "csgraph", + "args": [ + "mcp", + "--binding", + "${PLUGIN_ROOT}/.local/code-system-graph/mcp-binding.json" + ], + "env": { + "CODE_SYSTEM_GRAPH_MCP_ADMIN": "0" + } + } + } +} diff --git a/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/metadata/openai-default-prompt.txt b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/metadata/openai-default-prompt.txt new file mode 100644 index 0000000..16a8666 --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/metadata/openai-default-prompt.txt @@ -0,0 +1 @@ +Use ${{SKILL_NAME}} to investigate the current task with workspace graph evidence. diff --git a/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/metadata/openai-display-name.txt b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/metadata/openai-display-name.txt new file mode 100644 index 0000000..51b626c --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/metadata/openai-display-name.txt @@ -0,0 +1 @@ +Code System Graph ({{WORKSPACE}}) diff --git a/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/metadata/skill-description.txt b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/metadata/skill-description.txt new file mode 100644 index 0000000..be6dfff --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/metadata/skill-description.txt @@ -0,0 +1 @@ +Use Code System Graph for tasks in workspace `{{WORKSPACE}}` that need repository discovery, source navigation, dependencies, contracts, architecture, change impact, or cross-repository evidence. Do not use it for tasks outside that workspace. diff --git a/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/plugin.json b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/plugin.json new file mode 100644 index 0000000..0a77fc2 --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "{{PLUGIN_NAME}}", + "version": "{{PLUGIN_VERSION}}", + "description": "Workspace graph tools and usage skill for {{WORKSPACE_JSON_STRING}}", + "homepage": "https://github.com/dertin/code-system-graph", + "repository": "https://github.com/dertin/code-system-graph", + "license": "Apache-2.0", + "keywords": ["code-intelligence", "graph", "mcp", "workspace"] +} diff --git a/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/schemas/1.0.0/mcp.schema.json b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/schemas/1.0.0/mcp.schema.json new file mode 100644 index 0000000..1b2e34c --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/schemas/1.0.0/mcp.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "title": "Agent Plugins MCP Configuration", + "type": "object", + "properties": { + "$schema": { "const": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json" }, + "mcpServers": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/server" } + } + }, + "required": ["$schema", "mcpServers"], + "additionalProperties": false, + "$defs": { + "server": { + "oneOf": [ + { "$ref": "#/$defs/stdioServer" }, + { "$ref": "#/$defs/streamableHttpServer" }, + { "$ref": "#/$defs/sseServer" } + ] + }, + "stdioServer": { + "type": "object", + "properties": { + "type": { "const": "stdio" }, + "command": { "type": "string", "minLength": 1 }, + "args": { "type": "array", "items": { "type": "string" } }, + "env": { + "type": "object", + "propertyNames": { "not": { "enum": ["PLUGIN_ROOT", "PLUGIN_DATA"] } }, + "additionalProperties": { "type": "string" } + }, + "cwd": { + "type": "string", + "pattern": "^(?:\\./|\\$\\{PLUGIN_ROOT\\}(?:/|$)|\\$\\{PLUGIN_DATA\\}(?:/|$))" + } + }, + "required": ["type", "command"], + "additionalProperties": false + }, + "streamableHttpServer": { + "type": "object", + "properties": { + "type": { "const": "streamable-http" }, + "url": { "type": "string", "minLength": 1 }, + "headers": { "$ref": "#/$defs/headers" } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + "sseServer": { + "type": "object", + "properties": { + "type": { "const": "sse" }, + "url": { "type": "string", "minLength": 1 }, + "headers": { "$ref": "#/$defs/headers" } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } +} diff --git a/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/schemas/1.0.0/plugin.schema.json b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/schemas/1.0.0/plugin.schema.json new file mode 100644 index 0000000..5dc047a --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/schemas/1.0.0/plugin.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "title": "Agent Plugins Manifest", + "type": "object", + "properties": { + "$schema": { "const": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^(?!.*(?:--|\\.\\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$" + }, + "version": { "type": "string" }, + "description": { "type": "string" }, + "author": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "email": { "type": "string" }, + "url": { "type": "string" } + }, + "additionalProperties": false + }, + "homepage": { "type": "string" }, + "repository": { "type": "string" }, + "license": { "type": "string" }, + "keywords": { "type": "array", "items": { "type": "string" } }, + "extensions": { "type": "object", "additionalProperties": { "type": "object" } } + }, + "required": ["$schema", "name"], + "additionalProperties": false +} diff --git a/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/skills/code-system-graph/SKILL.md b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/skills/code-system-graph/SKILL.md new file mode 100644 index 0000000..7fbc2b2 --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/skills/code-system-graph/SKILL.md @@ -0,0 +1,60 @@ +--- +name: "{{SKILL_NAME}}" +description: {{SKILL_DESCRIPTION_YAML}} +--- + +# Code System Graph + +Use Code System Graph to find relevant repositories and entities, follow +relationships, and support conclusions with workspace graph evidence. Confirm +current behavior in live source, Git state, and focused tests. + +## Verify the workspace + +1. Use this skill only when the task belongs to workspace `{{WORKSPACE}}` and + the nearest relevant `code-system-graph.yaml` declares that name. +2. Call `status` before any other Code System Graph MCP tool. Confirm the + workspace and note freshness, partial coverage, or degradation that affects + the task. +3. If the MCP is unavailable or reports another workspace, stop using it. Do + not guess paths or reconfigure graph tooling; continue with live repository + evidence and report the limitation. + +## Choose the smallest useful tool + +| Goal | MCP tool | +| --- | --- | +| Find repositories, entities, capabilities, or known terms | `query` | +| Inspect persisted context for a known entity | `source_context` | +| Find architectural or subsystem groupings | `communities` | +| Find a path between two resolved entities | `trace` | +| Estimate upstream or downstream change risk | `impact` | +| Inspect or validate API and data contracts | `contracts` | +| Analyze working-tree or staged changes | `analyze_changes` | +| Analyze a pull request and cross-repository overlap | `analyze_pull_request` | +| Inspect repository-local source, symbols, call paths, or tests | `explore`, when available | + +Use `query` before tools that require stable node identifiers; never invent an +identifier. Keep scopes, depths, and result limits bounded. If results are +ambiguous or truncated, refine the question or paginate before broadening it. +Call only the tools needed for the current task. + +## Use graph evidence correctly + +- Use graph results to choose where to inspect and which relationships to + verify; do not treat them as a replacement for live code or tests. +- Verify implementation claims with `explore` or focused source reads. Verify + behavior changes with relevant tests when the task requires it. +- State only freshness, coverage, degradation, or evidence gaps that materially + affect the conclusion. +- Do not infer that an entity or relationship does not exist when the relevant + graph layer is stale, partial, unsupported, or capped. + +## Keep maintenance explicit + +Do not run `scan`, `sync`, `codegraph init`, admin tools, or configuration +mutations unless the user explicitly requests graph maintenance or repository +onboarding. For those tasks, read +[the operating guide](references/operating-guide.md) completely before acting. +Use live `--help` for the installed versions and make the smallest justified +change. diff --git a/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/skills/code-system-graph/agents/openai.yaml b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/skills/code-system-graph/agents/openai.yaml new file mode 100644 index 0000000..3aab20e --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/skills/code-system-graph/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: {{SKILL_DISPLAY_NAME_YAML}} + short_description: "Workspace graph navigation and impact" + default_prompt: {{SKILL_DEFAULT_PROMPT_YAML}} diff --git a/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/skills/code-system-graph/references/operating-guide.md b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/skills/code-system-graph/references/operating-guide.md new file mode 100644 index 0000000..ba4ec26 --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/agent-plugin/skills/code-system-graph/references/operating-guide.md @@ -0,0 +1,103 @@ +# Code System Graph operating guide + +Use this reference only for graph maintenance, repository onboarding, +configuration, freshness diagnosis, or scan-limit failures. + +Inspect the installed `csgraph` and `codegraph` versions rather than assuming a +specific release is active. Use the official documentation for those versions +and their live CLI help when behavior differs. + +## Inspect and refresh + +Run commands from the workspace root or pass explicit manifest and database +paths: + +```bash +csgraph --version +codegraph --version +csgraph config show --config +csgraph status --config --database +csgraph doctor --config --database +codegraph status +``` + +Use an incremental refresh for normal maintenance explicitly requested by the +user: + +```bash +csgraph sync --config --database +``` + +That command synchronizes initialized CodeGraph indexes by default. Do not add +`--no-codegraph` when validating an enabled integration. For an intentional full +graph recomputation, use: + +```bash +csgraph scan --config --database --codegraph --force +``` + +Run `status` and `doctor` again after either operation. Inspect structured JSON +when exact counts or diagnostics matter. + +## Exclusions and limits + +Set `useGitignore: true` on a repository entry or in local configuration when +tracked `.gitignore` rules should apply. Use explicit `excludes` for protected +or workspace-specific exclusions, which continue to win over `.gitignore` +negations. Confirm the effective policy and its origin with `csgraph config show +--repo ` and the next scan's discovered-input counts. + +Exclude generated and non-source content such as build output, virtual +environments, caches, rendered documentation, binary media, test reports, and +secret-bearing local environment files. Keep graph databases and tool indexes +out of source discovery. + +For a scan-limit failure: + +1. Capture the exact resource, repository, file, and reported limit. +2. Decide whether the artifact is generated, irrelevant, malformed, or valid + source evidence. +3. Exclude generated or irrelevant artifacts at the narrowest stable pattern. +4. Split or correct malformed source data when that preserves its contract. +5. Increase only the documented matching budget when the valid artifact must + remain indexed. +6. Use the smallest sufficient value, rescan, and record the residual risk. + +Do not increase global limits merely to hide an unknown input. Do not bypass a +fixed tool cap; report it as an evidence limitation. + +## Repository onboarding + +When adding a repository to the bound workspace: + +1. Confirm it is the intended Git repository and read its instructions. +2. Initialize CodeGraph only if repository indexing is desired and supported: + `codegraph init `. +3. Add one stable alias and path to the workspace manifest. +4. Decide explicitly whether to enable `useGitignore`; add narrower explicit + exclusions when workspace policy requires them. +5. Run `csgraph config show --repo ` before scanning. +6. Run an incremental sync, or a first `scan --codegraph` when no snapshot + exists. +7. Validate Code System Graph status, CodeGraph status, repository counts, and + at least one meaningful query. +8. Exercise one trace, impact, or contract check if the repository has an + expected connection to another repository. + +An index with zero supported source files can be legitimate. Record the +language or content limitation instead of claiming missing files were indexed. + +## Freshness interpretation + +Keep these states separate: + +- A Code System Graph snapshot can be structurally healthy but stale relative + to Git or filesystem changes. +- A CodeGraph index can have zero pending indexed-language files while the Git + worktree is still dirty because of unsupported or excluded files. +- A repository can be registered but contribute no nodes or evidence. +- Repository source coverage can be bounded or capped without invalidating the + workspace snapshot. + +Report the exact layer and evidence. Avoid collapsing all four into one +"synchronized" result. diff --git a/crates/code-system-graph-hooks/agent-integration-template/native-hooks/cursor-rule.mdc b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/cursor-rule.mdc new file mode 100644 index 0000000..f79ab5e --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/cursor-rule.mdc @@ -0,0 +1,6 @@ +--- +description: Code System Graph routing guidance ({{PRODUCT_MARKER}}) +alwaysApply: true +--- + +{{BLOCK}} diff --git a/crates/code-system-graph-hooks/agent-integration-template/native-hooks/guidance/federated-codegraph.txt b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/guidance/federated-codegraph.txt new file mode 100644 index 0000000..62bddaf --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/guidance/federated-codegraph.txt @@ -0,0 +1 @@ +This task may span repositories. Use Code System Graph to find the relevant entities, relationships, contracts, and impact; use `explore` for repository-local source evidence. Follow the installed Code System Graph skill when available. diff --git a/crates/code-system-graph-hooks/agent-integration-template/native-hooks/guidance/federated-native.txt b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/guidance/federated-native.txt new file mode 100644 index 0000000..bb8e988 --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/guidance/federated-native.txt @@ -0,0 +1 @@ +This task may span repositories. Use Code System Graph to find the relevant entities, relationships, contracts, and impact, then verify conclusions in live source. Follow the installed Code System Graph skill when available. diff --git a/crates/code-system-graph-hooks/agent-integration-template/native-hooks/guidance/local-codegraph.txt b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/guidance/local-codegraph.txt new file mode 100644 index 0000000..8c20a13 --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/guidance/local-codegraph.txt @@ -0,0 +1 @@ +Use `explore` to locate relevant symbols, call paths, implementation, and tests before broad filesystem searches. Follow the installed Code System Graph skill when available. diff --git a/crates/code-system-graph-hooks/agent-integration-template/native-hooks/guidance/local-native.txt b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/guidance/local-native.txt new file mode 100644 index 0000000..7f7f90a --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/guidance/local-native.txt @@ -0,0 +1 @@ +Use `query` and `source_context` for persisted repository entities and relationships, then verify conclusions in live source. Follow the installed Code System Graph skill when available. diff --git a/crates/code-system-graph-hooks/agent-integration-template/native-hooks/limitations/antigravity.txt b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/limitations/antigravity.txt new file mode 100644 index 0000000..7a4e9ab --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/limitations/antigravity.txt @@ -0,0 +1 @@ +Antigravity IDE does not document a stable project-file prompt hook protocol; installed a workspace rule instead. diff --git a/crates/code-system-graph-hooks/agent-integration-template/native-hooks/limitations/cursor.txt b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/limitations/cursor.txt new file mode 100644 index 0000000..218ae88 --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/limitations/cursor.txt @@ -0,0 +1 @@ +Cursor beforeSubmitPrompt can allow or block but cannot inject advisory routing context; installed an always-on project rule instead. diff --git a/crates/code-system-graph-hooks/agent-integration-template/native-hooks/metadata/gemini-description.txt b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/metadata/gemini-description.txt new file mode 100644 index 0000000..5ea7104 --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/metadata/gemini-description.txt @@ -0,0 +1 @@ +Select Code System Graph or CodeGraph from prompt intent diff --git a/crates/code-system-graph-hooks/agent-integration-template/native-hooks/metadata/status-message.txt b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/metadata/status-message.txt new file mode 100644 index 0000000..69ea139 --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/metadata/status-message.txt @@ -0,0 +1 @@ +Selecting repository intelligence diff --git a/crates/code-system-graph-hooks/agent-integration-template/native-hooks/signals/federated.txt b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/signals/federated.txt new file mode 100644 index 0000000..0c1745d --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/signals/federated.txt @@ -0,0 +1,51 @@ +cross-repo +cross repo +cross-repository +cross repository +multiple repos +multiple repositories +across repos +across repositories +between repos +between repositories +workspace-wide +workspace wide +repository boundary +service boundary +contract between +contract across +api boundary between +api boundary across +dependency between +dependency across +impact between +impact across +blast radius +pr overlap +pull request overlap +overlapping pr +overlapping pull request +federated +entre repos +entre repositorios +varios repos +varios repositorios +múltiples repositorios +multiples repositorios +a través de repositorios +a traves de repositorios +en todo el workspace +límite de repositorio +limite de repositorio +límite de servicio +limite de servicio +contrato entre +contrato a través +contrato a traves +api entre repositorios +dependencia entre repos +dependencia entre repositorios +impacto entre +radio de impacto +solapamiento de pr +solapamiento de pull request diff --git a/crates/code-system-graph-hooks/agent-integration-template/native-hooks/signals/local.txt b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/signals/local.txt new file mode 100644 index 0000000..9b3df57 --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/signals/local.txt @@ -0,0 +1,47 @@ +call path +caller +callee +call site +affected test +find symbol +locate symbol +symbol definition +implementation of +where is this defined +where is it defined +where is the implementation +where does this call +where does it call +repository structure +module dependency +dependency path +local impact +source context +ruta de llamadas +quién llama +quien llama +sitio de llamada +punto de llamada +pruebas afectadas +tests afectados +buscar símbolo +buscar simbolo +localizar símbolo +localizar simbolo +definición del símbolo +definicion del simbolo +implementación de +implementacion de +dónde está definido +donde esta definido +dónde se define +donde se define +dónde se implementa +donde se implementa +estructura del repositorio +dependencia de módulo +dependencia de modulo +ruta de dependencias +impacto local +contexto de código +contexto de codigo diff --git a/crates/code-system-graph-hooks/agent-integration-template/native-hooks/static-routing-codegraph.md b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/static-routing-codegraph.md new file mode 100644 index 0000000..44b2619 --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/static-routing-codegraph.md @@ -0,0 +1,2 @@ +- For repository-local navigation, use `explore` for symbols, call paths, implementation, and tests. +- For cross-repository questions, use Code System Graph to find entities, relationships, contracts, change impact, and pull-request overlap; use `explore` to verify local source details. diff --git a/crates/code-system-graph-hooks/agent-integration-template/native-hooks/static-routing-native.md b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/static-routing-native.md new file mode 100644 index 0000000..e658b1a --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/static-routing-native.md @@ -0,0 +1,2 @@ +- For repository-local questions, use `query` and `source_context` for persisted entities and relationships, then verify conclusions in live source. +- For cross-repository questions, use Code System Graph to find entities, relationships, contracts, change impact, and pull-request overlap. diff --git a/crates/code-system-graph-hooks/agent-integration-template/native-hooks/static-rule.md b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/static-rule.md new file mode 100644 index 0000000..6c7b642 --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/static-rule.md @@ -0,0 +1,7 @@ +{{BEGIN_MARKER}} +# Code System Graph routing + +{{ROUTING}} +- Follow the installed Code System Graph skill when available. +- Do not run scans, synchronization, CodeGraph initialization, or graph mutations unless the user explicitly requests graph maintenance. +{{END_MARKER}} diff --git a/crates/code-system-graph-hooks/agent-integration-template/native-hooks/strict-gate.sh b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/strict-gate.sh new file mode 100644 index 0000000..8daaaec --- /dev/null +++ b/crates/code-system-graph-hooks/agent-integration-template/native-hooks/strict-gate.sh @@ -0,0 +1,12 @@ +{{BEGIN_MARKER}} +CODE_SYSTEM_GRAPH_RESULT="$({{BINARY}} changes --scope staged --database {{DATABASE}} --workspace {{WORKSPACE}} --repository {{REPOSITORY}})" || { + echo "Code System Graph staged-change analysis failed; commit blocked by strict mode." >&2 + exit 1 +} +CODE_SYSTEM_GRAPH_FINGERPRINT="$(printf '%s' "$CODE_SYSTEM_GRAPH_RESULT" | tr -d '\n' | sed -n 's/.*"exact_diff_fingerprint":"\([^"]*\)".*/\1/p')" +if [ -z "$CODE_SYSTEM_GRAPH_FINGERPRINT" ]; then + echo "Code System Graph returned no exact staged fingerprint; commit blocked by strict mode." >&2 + exit 1 +fi +unset CODE_SYSTEM_GRAPH_RESULT CODE_SYSTEM_GRAPH_FINGERPRINT +{{END_MARKER}} diff --git a/crates/code-system-graph-hooks/src/install.rs b/crates/code-system-graph-hooks/src/install.rs index c25ee79..42ef8f2 100644 --- a/crates/code-system-graph-hooks/src/install.rs +++ b/crates/code-system-graph-hooks/src/install.rs @@ -6,6 +6,9 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use crate::managed_root::{MAX_HOST_FILE_BYTES, ManagedRoot}; +use crate::templates::{ + ANTIGRAVITY_LIMITATION, CURSOR_LIMITATION, CURSOR_RULE, GEMINI_HOOK_DESCRIPTION, HOOK_STATUS_MESSAGE, STATIC_ROUTING_CODEGRAPH, STATIC_ROUTING_NATIVE, STATIC_RULE, STRICT_GATE +}; use crate::types::{ HookError, HookMode, HookStatus, HostKind, InstallReport, InstallRequest, UninstallReport }; @@ -13,8 +16,6 @@ use crate::types::{ const PRODUCT_MARKER: &str = "code-system-graph-hooks:v1"; const STATE_DIRECTORY: &str = ".code-system-graph/hooks"; const GENERATED_STATE_IGNORE_RULE: &[u8] = b".code-system-graph/"; -const CURSOR_LIMITATION: &str = "Cursor beforeSubmitPrompt can allow or block but cannot inject advisory routing context; installed an always-on project rule instead."; -const ANTIGRAVITY_LIMITATION: &str = "Antigravity IDE does not document a stable project-file prompt hook protocol; installed a workspace rule instead."; #[derive(Debug, Clone, Copy)] enum HostProtocol { @@ -241,12 +242,12 @@ fn host_spec(request: &InstallRequest) -> HostSpec { HostKind::Antigravity => ( ".agents/rules/code-system-graph-routing.md", HostProtocol::Guidance, - Some(ANTIGRAVITY_LIMITATION), + Some(ANTIGRAVITY_LIMITATION.trim_end()), ), HostKind::Cursor => ( ".cursor/rules/code-system-graph-routing.mdc", HostProtocol::Guidance, - Some(CURSOR_LIMITATION), + Some(CURSOR_LIMITATION.trim_end()), ), }; HostSpec { @@ -392,7 +393,7 @@ fn owned_json_entry_with_policy( "type": "command", "command": command, "timeout": 5, - "statusMessage": "Selecting repository intelligence" + "statusMessage": HOOK_STATUS_MESSAGE.trim_end() }] }), HostKind::Gemini => json!({ @@ -402,7 +403,7 @@ fn owned_json_entry_with_policy( "type": "command", "command": command, "timeout": 5000, - "description": "Select Code System Graph or CodeGraph from prompt intent" + "description": GEMINI_HOOK_DESCRIPTION.trim_end() }] }), HostKind::Antigravity | HostKind::Cursor => Value::Null, @@ -508,8 +509,9 @@ fn install_guidance( fn guidance_scaffold(host: HostKind, block: &str) -> String { if host == HostKind::Cursor { - format!( - "---\ndescription: Code System Graph routing guidance ({PRODUCT_MARKER})\nalwaysApply: true\n---\n\n{block}" + render_embedded_template( + CURSOR_RULE, + &[("PRODUCT_MARKER", PRODUCT_MARKER), ("BLOCK", block)], ) } else { block.to_owned() @@ -518,14 +520,17 @@ fn guidance_scaffold(host: HostKind, block: &str) -> String { fn guidance_block(host: HostKind, codegraph_enabled: bool) -> String { let routing = if codegraph_enabled { - "- For work local to this repository, use Code System Graph explore first and use CodeGraph directly only if the provider is degraded.\n- For cross-repository work, contracts, architecture, impact, diffs, or pull-request overlap, use Code System Graph first and explore for local symbol detail." + STATIC_ROUTING_CODEGRAPH.trim_end() } else { - "- For repository-local work, use Code System Graph only for persisted entities, relationships, and source-free evidence; local source and symbol detail is unavailable in the native-only profile.\n- For cross-repository work, contracts, architecture, impact, diffs, or pull-request overlap, use Code System Graph first." + STATIC_ROUTING_NATIVE.trim_end() }; - format!( - "{begin}\n# Code System Graph intelligence routing\n\nClassify only the user's submitted prompt. Do not quote, copy, or inject the prompt itself.\n\n{routing}\n- Never automatically run scans, CodeGraph init or sync, source queries, or mutations because of this rule.\n- Keep routing guidance brief and advisory.\n{end}\n", - begin = begin_marker(host), - end = end_marker(host) + render_embedded_template( + STATIC_RULE, + &[ + ("BEGIN_MARKER", &begin_marker(host)), + ("ROUTING", routing), + ("END_MARKER", &end_marker(host)), + ], ) } @@ -543,10 +548,7 @@ fn remove_guidance( return Ok(false); }; let generated_cursor_scaffold = request.host == HostKind::Cursor - && updated.trim() - == format!( - "---\ndescription: Code System Graph routing guidance ({PRODUCT_MARKER})\nalwaysApply: true\n---" - ); + && updated.trim() == guidance_scaffold(HostKind::Cursor, "").trim(); backup(managed, relative, backups)?; if updated.trim().is_empty() || generated_cursor_scaffold { managed.remove_file_if_exists(relative)?; @@ -604,23 +606,37 @@ fn install_strict_gate( } fn strict_gate_block(request: &InstallRequest) -> String { - format!( - "{begin}\nCODE_SYSTEM_GRAPH_RESULT=\"$({binary} changes --scope staged --database {database} --workspace {workspace} --repository {repository})\" || {{\n echo \"Code System Graph staged-change analysis failed; commit blocked by strict mode.\" >&2\n exit 1\n}}\nCODE_SYSTEM_GRAPH_FINGERPRINT=\"$(printf '%s' \"$CODE_SYSTEM_GRAPH_RESULT\" | tr -d '\\n' | sed -n 's/.*\"exact_diff_fingerprint\":\"\\([^\"]*\\)\".*/\\1/p')\"\nif [ -z \"$CODE_SYSTEM_GRAPH_FINGERPRINT\" ]; then\n echo \"Code System Graph returned no exact staged fingerprint; commit blocked by strict mode.\" >&2\n exit 1\nfi\nunset CODE_SYSTEM_GRAPH_RESULT CODE_SYSTEM_GRAPH_FINGERPRINT\n{end}\n", - begin = begin_marker(request.host), - binary = shell_quote( - request - .code_system_graph_binary - .as_os_str() - .to_string_lossy() - .as_ref() - ), - database = shell_quote(request.database.as_os_str().to_string_lossy().as_ref()), - workspace = shell_quote(&request.workspace), - repository = shell_quote(&request.repository), - end = end_marker(request.host) + let binary = shell_quote( + request + .code_system_graph_binary + .as_os_str() + .to_string_lossy() + .as_ref(), + ); + let database = shell_quote(request.database.as_os_str().to_string_lossy().as_ref()); + let workspace = shell_quote(&request.workspace); + let repository = shell_quote(&request.repository); + render_embedded_template( + STRICT_GATE, + &[ + ("BEGIN_MARKER", &begin_marker(request.host)), + ("BINARY", &binary), + ("DATABASE", &database), + ("WORKSPACE", &workspace), + ("REPOSITORY", &repository), + ("END_MARKER", &end_marker(request.host)), + ], ) } +fn render_embedded_template(template: &str, variables: &[(&str, &str)]) -> String { + variables + .iter() + .fold(template.to_owned(), |rendered, (name, value)| { + rendered.replace(&format!("{{{{{name}}}}}"), value) + }) +} + fn remove_strict_gate( managed: &ManagedRoot, request: &InstallRequest, @@ -980,7 +996,7 @@ fn make_executable(_path: &Path) -> Result<(), HookError> { mod tests { use std::path::PathBuf; - use super::{guidance_block, owned_json_entry}; + use super::{guidance_block, guidance_scaffold, owned_json_entry, strict_gate_block}; use crate::types::{HookMode, HostKind, InstallRequest}; fn request(host: HostKind, codegraph_enabled: bool) -> InstallRequest { @@ -1002,6 +1018,10 @@ mod tests { let enriched = guidance_block(HostKind::Cursor, true); assert!(!native.contains("explore")); assert!(enriched.contains("explore")); + assert!(!native.contains("{{")); + assert!(!enriched.contains("{{")); + assert!(!guidance_scaffold(HostKind::Cursor, &native).contains("{{")); + assert!(!strict_gate_block(&request(HostKind::Codex, false)).contains("{{")); let runtime = PathBuf::from("/bin/code-system-graph-hooks"); let native_hook = owned_json_entry(&request(HostKind::Codex, false), &runtime).to_string(); diff --git a/crates/code-system-graph-hooks/src/lib.rs b/crates/code-system-graph-hooks/src/lib.rs index 7c7b41a..4148a07 100644 --- a/crates/code-system-graph-hooks/src/lib.rs +++ b/crates/code-system-graph-hooks/src/lib.rs @@ -8,6 +8,7 @@ mod install; mod managed_root; mod routing; +pub mod templates; mod types; pub use install::{install, status, uninstall}; diff --git a/crates/code-system-graph-hooks/src/routing.rs b/crates/code-system-graph-hooks/src/routing.rs index b6207fa..78e77a2 100644 --- a/crates/code-system-graph-hooks/src/routing.rs +++ b/crates/code-system-graph-hooks/src/routing.rs @@ -7,52 +7,11 @@ use std::time::{SystemTime, UNIX_EPOCH}; use atomic_write_file::AtomicWriteFile; use serde::{Deserialize, Serialize}; +use crate::templates::{ + FEDERATED_CODEGRAPH_GUIDANCE, FEDERATED_NATIVE_GUIDANCE, FEDERATED_SIGNALS, LOCAL_CODEGRAPH_GUIDANCE, LOCAL_NATIVE_GUIDANCE, LOCAL_SIGNALS +}; use crate::types::{HookError, RoutingIntent, RoutingRequest, RoutingResponse}; -const FEDERATED_CODEGRAPH_GUIDANCE: &str = "Use Code System Graph first for federated contracts, architecture, impact, diff, or PR-overlap context; use explore for repository-local source and symbol detail."; -const LOCAL_CODEGRAPH_GUIDANCE: &str = "Use Code System Graph explore first for repository-local symbols, callers, tests, and implementation detail; use CodeGraph directly only if the provider is degraded."; -const FEDERATED_NATIVE_GUIDANCE: &str = "Use Code System Graph first for federated contracts, architecture, impact, diff, or PR-overlap context. Repository-local source and symbol detail is unavailable in the native-only profile."; -const LOCAL_NATIVE_GUIDANCE: &str = "Use Code System Graph for persisted repository entities, relationships, and source-free evidence. Repository-local source and symbol detail is unavailable in the native-only profile."; - -const FEDERATED_SIGNALS: &[&str] = &[ - "cross-repo", - "cross repo", - "multiple repos", - "across repos", - "contract", - "api boundary", - "architecture", - "architectural", - "impact", - "blast radius", - "diff", - "pull request", - "pull-request", - "pr overlap", - "overlapping pr", - "dependency graph", - "service boundary", - "federated", -]; - -const LOCAL_SIGNALS: &[&str] = &[ - "repository", - "repo", - "code", - "symbol", - "function", - "method", - "class", - "module", - "file", - "test", - "bug", - "refactor", - "implement", - "caller", - "call site", -]; - #[derive(Debug, Default, Serialize, Deserialize)] struct DedupState { entries: BTreeMap, @@ -62,21 +21,23 @@ struct DedupState { #[must_use] pub fn classify_prompt(prompt: &str) -> RoutingIntent { let normalized = prompt.to_lowercase(); - if FEDERATED_SIGNALS - .iter() - .any(|signal| normalized.contains(signal)) - { + if contains_signal(FEDERATED_SIGNALS, &normalized) { RoutingIntent::Federated - } else if LOCAL_SIGNALS - .iter() - .any(|signal| normalized.contains(signal)) - { + } else if contains_signal(LOCAL_SIGNALS, &normalized) { RoutingIntent::LocalRepository } else { RoutingIntent::None } } +fn contains_signal(signals: &str, normalized_prompt: &str) -> bool { + signals + .lines() + .map(str::trim) + .filter(|signal| !signal.is_empty()) + .any(|signal| normalized_prompt.contains(signal)) +} + /// Classifies one host event and applies session/repository TTL deduplication. /// /// Only the top-level `prompt` and optional `session_id` fields are read. Prompt text is never @@ -134,10 +95,10 @@ pub fn route(request: &RoutingRequest) -> Result { fn guidance_for(intent: RoutingIntent, codegraph_enabled: bool) -> Option<&'static str> { match (intent, codegraph_enabled) { (RoutingIntent::None, _) => None, - (RoutingIntent::LocalRepository, true) => Some(LOCAL_CODEGRAPH_GUIDANCE), - (RoutingIntent::Federated, true) => Some(FEDERATED_CODEGRAPH_GUIDANCE), - (RoutingIntent::LocalRepository, false) => Some(LOCAL_NATIVE_GUIDANCE), - (RoutingIntent::Federated, false) => Some(FEDERATED_NATIVE_GUIDANCE), + (RoutingIntent::LocalRepository, true) => Some(LOCAL_CODEGRAPH_GUIDANCE.trim_end()), + (RoutingIntent::Federated, true) => Some(FEDERATED_CODEGRAPH_GUIDANCE.trim_end()), + (RoutingIntent::LocalRepository, false) => Some(LOCAL_NATIVE_GUIDANCE.trim_end()), + (RoutingIntent::Federated, false) => Some(FEDERATED_NATIVE_GUIDANCE.trim_end()), } } @@ -234,6 +195,10 @@ mod tests { assert!(!native.contains("explore")); assert!(enriched.contains("explore")); + assert!(native.contains("Follow the installed Code System Graph skill")); + assert!(enriched.contains("Follow the installed Code System Graph skill")); + assert!(!native.contains("routing path")); + assert!(!enriched.contains("routing path")); } } } diff --git a/crates/code-system-graph-hooks/src/templates.rs b/crates/code-system-graph-hooks/src/templates.rs new file mode 100644 index 0000000..d8108cf --- /dev/null +++ b/crates/code-system-graph-hooks/src/templates.rs @@ -0,0 +1,195 @@ +//! Visible, versioned templates shared by Agent Plugin generation and native host adapters. + +/// Portable Agent Plugins 1.0 manifest template. +pub const AGENT_PLUGIN_MANIFEST: &str = + include_str!("../agent-integration-template/agent-plugin/plugin.json"); +/// Portable Agent Plugins 1.0 MCP template. +pub const AGENT_PLUGIN_MCP: &str = + include_str!("../agent-integration-template/agent-plugin/mcp.json"); +/// Canonical cross-client Code System Graph skill. +pub const AGENT_PLUGIN_SKILL: &str = + include_str!("../agent-integration-template/agent-plugin/skills/code-system-graph/SKILL.md"); +/// Optional Codex UI metadata for the canonical skill. +pub const AGENT_PLUGIN_OPENAI_METADATA: &str = include_str!( + "../agent-integration-template/agent-plugin/skills/code-system-graph/agents/openai.yaml" +); +/// Extended operating guide referenced by the canonical skill. +pub const AGENT_PLUGIN_OPERATING_GUIDE: &str = include_str!( + "../agent-integration-template/agent-plugin/skills/code-system-graph/references/operating-guide.md" +); +/// License emitted in a complete portable plugin. +pub const AGENT_PLUGIN_LICENSE: &str = + include_str!("../agent-integration-template/agent-plugin/LICENSE"); +/// Ignore rule emitted for developer-local plugin bindings. +pub const AGENT_PLUGIN_GITIGNORE: &str = + include_str!("../agent-integration-template/agent-plugin/generated.gitignore"); +/// Skill discovery description rendered into the portable frontmatter. +pub const AGENT_PLUGIN_SKILL_DESCRIPTION: &str = + include_str!("../agent-integration-template/agent-plugin/metadata/skill-description.txt"); +/// Optional Codex display name for the canonical skill. +pub const AGENT_PLUGIN_OPENAI_DISPLAY_NAME: &str = + include_str!("../agent-integration-template/agent-plugin/metadata/openai-display-name.txt"); +/// Optional Codex starter prompt for the canonical skill. +pub const AGENT_PLUGIN_OPENAI_DEFAULT_PROMPT: &str = + include_str!("../agent-integration-template/agent-plugin/metadata/openai-default-prompt.txt"); + +/// Dynamic prompt-hook guidance for federated work with `CodeGraph` enrichment. +pub const FEDERATED_CODEGRAPH_GUIDANCE: &str = + include_str!("../agent-integration-template/native-hooks/guidance/federated-codegraph.txt"); +/// Dynamic prompt-hook guidance for repository-local work with `CodeGraph` enrichment. +pub const LOCAL_CODEGRAPH_GUIDANCE: &str = + include_str!("../agent-integration-template/native-hooks/guidance/local-codegraph.txt"); +/// Dynamic prompt-hook guidance for federated work without `CodeGraph` enrichment. +pub const FEDERATED_NATIVE_GUIDANCE: &str = + include_str!("../agent-integration-template/native-hooks/guidance/federated-native.txt"); +/// Dynamic prompt-hook guidance for repository-local work without `CodeGraph` enrichment. +pub const LOCAL_NATIVE_GUIDANCE: &str = + include_str!("../agent-integration-template/native-hooks/guidance/local-native.txt"); +/// Newline-separated prompt signals for federated intent. +pub const FEDERATED_SIGNALS: &str = + include_str!("../agent-integration-template/native-hooks/signals/federated.txt"); +/// Newline-separated prompt signals for repository-local intent. +pub const LOCAL_SIGNALS: &str = + include_str!("../agent-integration-template/native-hooks/signals/local.txt"); +/// Marker-scoped static-rule template used only as a compatibility fallback. +pub const STATIC_RULE: &str = + include_str!("../agent-integration-template/native-hooks/static-rule.md"); +/// Static routing policy when `CodeGraph` enrichment is enabled. +pub const STATIC_ROUTING_CODEGRAPH: &str = + include_str!("../agent-integration-template/native-hooks/static-routing-codegraph.md"); +/// Static routing policy when `CodeGraph` enrichment is unavailable. +pub const STATIC_ROUTING_NATIVE: &str = + include_str!("../agent-integration-template/native-hooks/static-routing-native.md"); +/// Cursor wrapper for the static compatibility rule. +pub const CURSOR_RULE: &str = + include_str!("../agent-integration-template/native-hooks/cursor-rule.mdc"); +/// Strict pre-commit gate shell template. +pub const STRICT_GATE: &str = + include_str!("../agent-integration-template/native-hooks/strict-gate.sh"); +/// Cursor limitation reported by hook installation. +pub const CURSOR_LIMITATION: &str = + include_str!("../agent-integration-template/native-hooks/limitations/cursor.txt"); +/// Antigravity limitation reported by hook installation. +pub const ANTIGRAVITY_LIMITATION: &str = + include_str!("../agent-integration-template/native-hooks/limitations/antigravity.txt"); +/// Claude/Codex hook status text. +pub const HOOK_STATUS_MESSAGE: &str = + include_str!("../agent-integration-template/native-hooks/metadata/status-message.txt"); +/// Gemini hook description. +pub const GEMINI_HOOK_DESCRIPTION: &str = + include_str!("../agent-integration-template/native-hooks/metadata/gemini-description.txt"); + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::{ + AGENT_PLUGIN_MANIFEST, AGENT_PLUGIN_MCP, AGENT_PLUGIN_OPENAI_DEFAULT_PROMPT, AGENT_PLUGIN_OPENAI_DISPLAY_NAME, AGENT_PLUGIN_OPENAI_METADATA, AGENT_PLUGIN_SKILL, AGENT_PLUGIN_SKILL_DESCRIPTION, CURSOR_RULE, FEDERATED_CODEGRAPH_GUIDANCE, FEDERATED_NATIVE_GUIDANCE, FEDERATED_SIGNALS, GEMINI_HOOK_DESCRIPTION, HOOK_STATUS_MESSAGE, LOCAL_CODEGRAPH_GUIDANCE, LOCAL_NATIVE_GUIDANCE, LOCAL_SIGNALS, STATIC_RULE, STRICT_GATE + }; + + #[test] + fn template_placeholder_contracts_should_be_explicit() { + assert_eq!( + placeholders(AGENT_PLUGIN_MANIFEST), + set(&["PLUGIN_NAME", "PLUGIN_VERSION", "WORKSPACE_JSON_STRING"]) + ); + assert_eq!(placeholders(AGENT_PLUGIN_MCP), set(&["MCP_SERVER_NAME"])); + assert_eq!( + placeholders(AGENT_PLUGIN_SKILL), + set(&["SKILL_DESCRIPTION_YAML", "SKILL_NAME", "WORKSPACE"]) + ); + assert_eq!( + placeholders(AGENT_PLUGIN_OPENAI_METADATA), + set(&["SKILL_DEFAULT_PROMPT_YAML", "SKILL_DISPLAY_NAME_YAML"]) + ); + assert_eq!( + placeholders(STATIC_RULE), + set(&["BEGIN_MARKER", "END_MARKER", "ROUTING"]) + ); + assert_eq!(placeholders(CURSOR_RULE), set(&["BLOCK", "PRODUCT_MARKER"])); + assert_eq!( + placeholders(AGENT_PLUGIN_SKILL_DESCRIPTION), + set(&["WORKSPACE"]) + ); + assert_eq!( + placeholders(AGENT_PLUGIN_OPENAI_DISPLAY_NAME), + set(&["WORKSPACE"]) + ); + assert_eq!( + placeholders(AGENT_PLUGIN_OPENAI_DEFAULT_PROMPT), + set(&["SKILL_NAME"]) + ); + assert_eq!( + placeholders(STRICT_GATE), + set(&[ + "BEGIN_MARKER", + "BINARY", + "DATABASE", + "END_MARKER", + "REPOSITORY", + "WORKSPACE", + ]) + ); + for template in [ + FEDERATED_CODEGRAPH_GUIDANCE, + FEDERATED_NATIVE_GUIDANCE, + LOCAL_CODEGRAPH_GUIDANCE, + LOCAL_NATIVE_GUIDANCE, + HOOK_STATUS_MESSAGE, + GEMINI_HOOK_DESCRIPTION, + ] { + assert_ne!(template.trim(), ""); + assert!(placeholders(template).is_empty()); + } + } + + #[test] + fn routing_signals_should_be_lowercase_specific_and_unique() { + let forbidden = set(&[ + "architecture", + "code", + "diff", + "donde", + "dónde", + "impact", + "pull request", + "repo", + "repository", + "test", + "where does", + "where is", + ]); + for signals in [FEDERATED_SIGNALS, LOCAL_SIGNALS] { + let mut observed = BTreeSet::new(); + for signal in signals.lines().map(str::trim) { + assert_ne!(signal, ""); + assert_eq!(signal, signal.to_lowercase()); + assert!( + observed.insert(signal), + "duplicate routing signal `{signal}`" + ); + assert!( + !forbidden.contains(signal), + "routing signal `{signal}` is too broad" + ); + } + } + } + + fn placeholders(template: &str) -> BTreeSet<&str> { + template + .split("{{") + .skip(1) + .map(|remainder| { + remainder + .split_once("}}") + .map(|(name, _)| name) + .expect("every template placeholder must close") + }) + .collect() + } + + fn set<'a>(values: &[&'a str]) -> BTreeSet<&'a str> { + values.iter().copied().collect() + } +} diff --git a/crates/code-system-graph-hooks/tests/host_hooks.rs b/crates/code-system-graph-hooks/tests/host_hooks.rs index 004969e..48d907f 100644 --- a/crates/code-system-graph-hooks/tests/host_hooks.rs +++ b/crates/code-system-graph-hooks/tests/host_hooks.rs @@ -279,6 +279,42 @@ fn classifier_prefers_federated_signals_over_local_signals() { ); } +#[test] +fn classifier_should_avoid_generic_coding_prompt_noise() { + assert_eq!( + classify_prompt("Fix the bug in this file and run its tests"), + RoutingIntent::None + ); + assert_eq!( + classify_prompt("Review this pull request diff"), + RoutingIntent::None + ); + assert_eq!( + classify_prompt("Where is deployment documentation published?"), + RoutingIntent::None + ); + assert_eq!( + classify_prompt("¿Dónde está publicada la documentación del despliegue?"), + RoutingIntent::None + ); + assert_eq!( + classify_prompt("Find callers of process_order"), + RoutingIntent::LocalRepository + ); + assert_eq!( + classify_prompt("Encuentra quién llama a process_order"), + RoutingIntent::LocalRepository + ); + assert_eq!( + classify_prompt("Check contract compatibility between repositories"), + RoutingIntent::Federated + ); + assert_eq!( + classify_prompt("Revisa el impacto entre repositorios"), + RoutingIntent::Federated + ); +} + struct Fixture { directory: TempDir, host: HostKind, diff --git a/crates/code-system-graph-hooks/tests/host_lifecycle.rs b/crates/code-system-graph-hooks/tests/host_lifecycle.rs index ad6e8e8..9a9ab83 100644 --- a/crates/code-system-graph-hooks/tests/host_lifecycle.rs +++ b/crates/code-system-graph-hooks/tests/host_lifecycle.rs @@ -23,7 +23,7 @@ fn uninstall_on_clean_repository_without_hooks_directory_should_be_noop() std::fs::write(temporary.path().join(".git/HEAD"), "ref: refs/heads/main\n")?; let removal = uninstall(&request(temporary.path(), HostKind::Cursor))?; assert!(!removal.changed); - assert!(removal.removed_files.is_empty()); + assert_eq!(removal.removed_files, Vec::::new()); Ok(()) } diff --git a/crates/code-system-graph-store-sqlite/Cargo.toml b/crates/code-system-graph-store-sqlite/Cargo.toml index d543693..3f0633c 100644 --- a/crates/code-system-graph-store-sqlite/Cargo.toml +++ b/crates/code-system-graph-store-sqlite/Cargo.toml @@ -13,7 +13,7 @@ categories = ["database", "development-tools"] [dependencies] blake3 = "1.8.5" -code-system-graph-model = { version = "1.0.2", path = "../code-system-graph-model" } +code-system-graph-model = { version = "1.0.3", path = "../code-system-graph-model" } rusqlite = { version = "0.40.1", features = ["backup", "bundled"] } same-file = "1.0.6" serde_json = "1.0.151" diff --git a/docs/AGENT_SETUP.md b/docs/AGENT_SETUP.md index df43459..346ecc6 100644 --- a/docs/AGENT_SETUP.md +++ b/docs/AGENT_SETUP.md @@ -1,17 +1,166 @@ # Connect a Coding Agent -Code System Graph works with any local MCP client that can start a stdio server. It also has -optional routing integrations for Claude Code, Codex, Gemini CLI, Antigravity, and Cursor. +Code System Graph works with any local MCP client that can start a stdio server. The preferred +installation is one plugin containing both the MCP declaration and one Agent Skill that explains +when and how to use the graph. + +Keep one source of detailed routing instructions: + +1. **Packaged Agent Skill, required when the client supports skills:** owns the concrete MCP + procedure and verifies the workspace before use. +2. **Native prompt hook, optional activation accelerator:** Claude Code, Codex, and Gemini can + classify submitted prompts and ask the agent to activate the installed skill. The hook must not + duplicate the skill procedure. +3. **Static project rule, last-resort fallback:** use only when neither packaged skills nor a + conditional prompt hook are available. + +Do not copy the skill into a global `AGENTS.md` or combine it with an always-on repository rule. +Those mechanisms repeat the same policy with broader activation semantics. An MCP connection +without a skill remains usable, but the brief hook guidance alone does not teach the full safe +workflow. + +## Portable Agent Plugin + +Clients implementing [Agent Plugins 1.0.0](https://agent-plugins.org/specification) can consume a +single generated package containing the MCP declaration, one portable Agent Skill, its operating +guide, the license, and an ignored local runtime binding: + +`crates/code-system-graph-hooks/agent-integration-template/` is the single versioned source for +editable agent installation, skill, discovery, and activation content. Its `agent-plugin/` +subtree owns the manifest, MCP declaration, canonical skill, operating guide, optional client +metadata, license, schemas, and ignore rule. Its +`native-hooks/` subtree owns classifier signals, dynamic guidance, fallback rules, strict-gate +shell, limitations, and host UI text. Rust includes and renders those files; it contains no second +skill, routing policy, or hook-script body. -There are two separate pieces: +```bash +csgraph plugin create \ + --output /absolute/path/to/code-system-graph-plugin \ + --config /absolute/path/to/code-system-graph.yaml \ + --database /absolute/path/to/.code-system-graph/code-system-graph.db \ + --codegraph +``` + +Omit `--codegraph` to keep `explore` unavailable, or add +`--codegraph-binary /absolute/path/to/codegraph` with the opt-in flag. Generation requires a valid +manifest and an existing matching snapshot. Stale snapshots are allowed and reported. `csgraph +1.0.3` must be in the client's `PATH`, and no platform binary is bundled. Repeating the command is a +no-op only for an exactly identical directory; conflicts, additional files, and symlinks fail +without partial writes. + +Only `.local/code-system-graph/mcp-binding.json` contains absolute paths for the current host, and +the generated `/.local/` rule excludes it. Commit the remaining plugin files when the team should +share them. After cloning, each developer uses +[existing-plugin mode](#bind-an-existing-portable-plugin) to generate that local binding. +The local binding also records the generating `csgraph` version, exact executable fingerprint, +and build-time source commit and dirty state when those Git values were available. This metadata +is diagnostic only and does not weaken runtime validation. + +The generated server forcibly disables the admin environment profile and never supplies +`--admin`. Its skill routes through `status`, then identity resolution and bounded graph tools, +and never initiates scans or mutations. Complete-plugin mode emits only portable skill files; it +does not add Codex UI metadata or another client's rules. A native prompt hook may complement this +skill by selecting it for relevant prompts, but the hook does not replace the procedure. + +Every generated package derives its stable name and suffix only from the manifest's declared +workspace name. The plugin, MCP server entry, and skill share that identity, so clones of one +logical workspace render the same versioned files. + +### Bind an existing portable plugin + +Use `plugin create` with both integration options when a repository already contains a versioned +base plugin and each developer needs a local workspace binding: + +```bash +csgraph plugin create \ + --output /absolute/path/to/base-plugin \ + --mcp-server-name existing-code-system-graph \ + --routing-skill existing-system-graph-skill \ + --config /absolute/path/to/code-system-graph.yaml \ + --database /absolute/path/to/.code-system-graph/code-system-graph.db \ + --codegraph +``` + +The versioned portable `mcp.json` entry, and the matching Codex entry when present, must use this +runtime shape: + +```json +{ + "type": "stdio", + "command": "csgraph", + "args": [ + "mcp", + "--binding", + "${PLUGIN_ROOT}/.local/code-system-graph/mcp-binding.json" + ], + "env": { + "CODE_SYSTEM_GRAPH_MCP_ADMIN": "0" + } +} +``` + +The command adds those entries and the generated routing skill when absent, validates matching +existing components, and requires an exact `/.local/` Git ignore rule. When the existing plugin +already has `.codex-plugin/plugin.json`, it also emits optional Codex UI metadata at +`agents/openai.yaml`; a portable-only plugin does not receive that client-specific file. It +preserves every unrelated manifest field, MCP server, and skill. The ignored +`.local/code-system-graph/` directory contains the runtime binding and an ownership receipt. +Both identify the exact generator build so an installation can be audited independently of the +versioned plugin files. +Recreating an identical integration is a no-op. Pass `--replace-generated` to update older owned +local state; unmanaged state and invalid ownership identities are rejected. When a newer generator +changes the versioned skill itself, run `plugin uninstall` first and then `plugin create`; its +receipt verifies the existing skill before removal instead of silently overwriting it. + +Remove only that managed integration before testing a clean reinstall: + +```bash +csgraph plugin uninstall \ + --output /absolute/path/to/base-plugin \ + --mcp-server-name existing-code-system-graph \ + --routing-skill existing-system-graph-skill +``` -1. **MCP connection, required for agent use:** gives the agent Code System Graph tools. -2. **Routing hook or rule, optional:** reminds the agent when to use the federated graph and when - repository-local CodeGraph context is more appropriate. +Uninstall verifies the receipt and skill hashes, removes the matching portable and Codex MCP +entries, routing skill, and local directory, and preserves all other plugin content. Modified or +unowned components cause a conflict instead of being deleted. Install the base plugin root in the +client. Before binding, the federated MCP fails to start but independent components remain +available. -Installing a routing hook without connecting MCP does not give the agent Code System Graph tools. +Agent Plugins 1.0.0 does not define conditional activation by the agent's current directory; plugin +installation and enablement are client-owned. For actual workspace-only activation, install the +generated package in the client's project-local configuration rooted at the directory containing +the workspace manifest, not globally. The generated skill also fails closed: it requires the task's +nearest manifest and MCP `status` to report the declared workspace. This routing guard prevents +incorrect use, but it cannot stop a client that globally launches every installed MCP process. -## Before connecting +### Packaging and marketplaces + +The reusable unit is the same `skills//` directory for every Agent Skills client. Do not +generate Claude-, Gemini-, Antigravity-, Cursor-, or Codex-specific copies of `SKILL.md`. A native +adapter may translate only discovery metadata and the MCP declaration: + +| Client | Preferred distribution | Adapter outside the portable core | +| --- | --- | --- | +| Codex | Codex marketplace or Agent Plugin installation | Existing `.codex-plugin/plugin.json`; optional `agents/openai.yaml` UI metadata | +| Cursor | Agent Plugin marketplace or local Agent Plugin | None for skills and MCP | +| Claude Code | Claude plugin or manual MCP plus the same skill | `.claude-plugin/plugin.json` and `.mcp.json` | +| Gemini CLI | Gemini extension or manual MCP plus the same skill | `gemini-extension.json` with `mcpServers` | +| Antigravity | Antigravity plugin or manual MCP plus the same skill | `mcp_config.json` | + +Agent Plugins 1.0 does not define one cross-vendor marketplace. Keep one canonical skill and MCP +model in source, then let each marketplace or installer stage the small native adapter it requires. +Do not place those native adapter files into a generated complete Agent Plugin and still describe +that full directory as the portable core. + +## Manual compatibility setup + +Use the following client-specific MCP registration only when the client cannot install the Agent +Plugin package. Pair it with the same generated Agent Skill when the client supports standalone +skills. A native prompt hook can improve activation for Claude Code, Codex, or Gemini without +duplicating the skill. Use a static routing rule only when the skill itself cannot be installed. + +### Before connecting Complete one scan and use absolute paths when the agent may start outside the workspace directory: @@ -28,18 +177,19 @@ initialize CodeGraph in every declared repository as described in [Use CodeGraph with a workspace](CODEGRAPH_INTEGRATION.md#set-up-codegraph-for-a-workspace). Remove `--codegraph` if you want only the federated Code System Graph tools. -## Supported agents +### Supported agents -| Agent | MCP configuration | Optional routing integration | +| Agent | Manual MCP configuration | Optional activation mechanism | | --- | --- | --- | -| Claude Code | `claude mcp add` | Native JSON prompt hook | -| Codex CLI, IDE extension, and app | `codex mcp add` or shared Codex settings | Native JSON prompt hook | -| Gemini CLI | `gemini mcp add` | Native JSON prompt hook | -| Antigravity | `.agents/mcp_config.json` or MCP settings | Project routing rule | -| Cursor | `.cursor/mcp.json` or MCP settings | Project routing rule | +| Claude Code | `claude mcp add` | Packaged skill, optionally selected by native JSON prompt hook | +| Codex CLI, IDE extension, and app | `codex mcp add` or shared Codex settings | Packaged skill, optionally selected by native JSON prompt hook | +| Gemini CLI | `gemini mcp add` | Packaged skill, optionally selected by native JSON prompt hook | +| Antigravity | `.agents/mcp_config.json` or MCP settings | Packaged skill; project rule only as fallback | +| Cursor | `.cursor/mcp.json` or MCP settings | Packaged skill; project rule only as fallback | -Antigravity and Cursor receive an always-on project rule because the current Code System Graph hook -adapter does not inject prompt context through their native lifecycle APIs. +When a static fallback is required, Antigravity and Cursor use a project rule because the current +Code System Graph adapter does not inject prompt context through their native lifecycle APIs. Do not +install that rule when either client already loaded the packaged Agent Skill. Agent configuration syntax can change independently of Code System Graph. The examples below were checked against the official MCP documentation for @@ -51,7 +201,7 @@ checked against the official MCP documentation for In the examples below, replace `/absolute/path/to/workspace` and `commerce`. -## Claude Code +### Claude Code Register a project-local stdio server: @@ -71,7 +221,7 @@ claude mcp get code-system-graph Inside Claude Code, `/mcp` shows connection status and discovered tools. -## Codex +### Codex Register the server: @@ -105,7 +255,7 @@ args = [ ] ``` -## Gemini CLI +### Gemini CLI Register a project-scoped server: @@ -125,7 +275,7 @@ gemini mcp list The current folder must be trusted before Gemini CLI starts a project stdio server. -## Antigravity +### Antigravity Create or merge `.agents/mcp_config.json` in the workspace: @@ -151,7 +301,7 @@ Open the MCP manager in Antigravity and confirm that `code-system-graph` is conn also supports a global configuration, but project scope keeps the workspace identity and database path together. -## Cursor +### Cursor Create or merge `/.cursor/mcp.json`. Do not put this Code System Graph entry in `~/.cursor/mcp.json`; the configuration-file location is what keeps the server scoped to this @@ -181,7 +331,13 @@ changing the file, then open MCP settings and confirm that `code-system-graph` a enabled. A separate global CodeGraph MCP entry would expose repository-local tools in every Cursor project and is not needed for this integration. -## Install optional routing +## Optional native activation + +For Claude Code, Codex, and Gemini, the installed native hook classifies prompt intent and emits a +short instruction to activate the packaged Code System Graph skill. The skill remains the only +detailed MCP procedure. For Antigravity and Cursor, this command writes a static project rule +instead; skip it when those clients can load the skill. Never add the guidance to a global +`AGENTS.md`. Run one explicit command for each agent and repository where routing guidance should be available: @@ -195,8 +351,8 @@ csgraph hooks install \ --database ../.code-system-graph/code-system-graph.db ``` -The hook writes project-local agent configuration and private state under -`.code-system-graph/hooks/`. The hook cannot inspect another process's MCP tool list, so its +The integration writes project-local agent configuration and private state under +`.code-system-graph/hooks/`. It cannot inspect another process's MCP tool list, so its policy is explicit: pass `--codegraph` only when the registered MCP command also uses `--codegraph` and advertises `explore`. Omit both flags for the native-only profile. Reinstall the hook after changing that MCP policy. diff --git a/docs/CLI.md b/docs/CLI.md index b1c1369..67e134b 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -36,6 +36,8 @@ csgraph status --database [--config ] csgraph doctor --database [--config ] csgraph diagnostics --database [--config ] --output csgraph backup|restore ... +csgraph plugin create --output [--mcp-server-name --routing-skill ] [--config ] [--database ] [--codegraph] [--replace-generated] +csgraph plugin uninstall --output --mcp-server-name --routing-skill ``` Normal scan reuses unchanged source-owned batches. `--repo` recomputes only the selected alias and @@ -44,8 +46,9 @@ scope. Doctor reports unavailable observations as unknown rather than healthy. `config show` resolves workspace and repository-local configuration without opening a database. Its deterministic JSON reports protected exclusions, reactivable built-in defaults, configured -`excludes` and `includeDefaults` in canonical form with their source, and the ordered effective -rules. The optional `--repo` filter requires one exact manifest alias. +`excludes` and `includeDefaults` in canonical form, effective `useGitignore`, their configuration +sources, and the ordered effective rules. The optional `--repo` filter requires one exact manifest +alias. `scan` and plain `sync` each perform one pass and exit. Neither command installs a watcher or background service. `sync` is incremental, but first runs `codegraph sync --quiet` with direct @@ -68,6 +71,48 @@ source-free doctor report. It refuses to overwrite an existing destination and c with mode `0600` on Unix. Review the bundle before sharing it; the command does not upload or send anything. +`plugin create` validates the manifest, existing database snapshot, and workspace identity before +atomically writing a strict portable-core Agent Plugins 1.0.0 directory and its ignored local +binding. It reports snapshot freshness and generated files as JSON. Re-running against byte-identical output +returns `changed: false`; different, additional, or symlinked entries are conflicts and remain +untouched. `--codegraph-binary` requires `--codegraph` and is resolved only inside the local +binding. Plugin, MCP, and skill names derive from the declared workspace name, making versioned +output stable across clones. The report distinguishes `complete_plugin` from `existing_plugin` +mode, records the ignored binding path, the canonical workspace root, and +`client_managed_project_local` activation scope because the Agent Plugins standard delegates +directory-based enablement to each client. + +Only `.local/code-system-graph/mcp-binding.json` embeds absolute host paths. The generated +`/.local/` rule excludes it; the remaining plugin files are portable and versionable. Developers +use existing-plugin mode with the plugin's MCP server and routing skill names to recreate the +binding after cloning. + +All generated content comes directly from +`crates/code-system-graph-hooks/agent-integration-template/agent-plugin/`, which Cargo packages +through the shared hooks crate consumed by the CLI. One portable MCP declaration consumes the ignored binding; strict `{{VAR}}` rendering +rejects unknown, missing, or unused template variables. Complete-plugin mode emits one standard +Agent Skill and no client-specific UI metadata. + +When `--mcp-server-name` and `--routing-skill` are supplied together, `plugin create` treats +`--output` as an existing portable plugin. It adds the named read-only server entry to `mcp.json` +and, when present, `.codex-plugin/plugin.json`, and generates the routing skill when absent. +Matching components are left unchanged; conflicting components fail closed. Unrelated MCP servers, +skills, and manifest fields are preserved. When a Codex manifest is present, the managed skill also +receives optional `agents/openai.yaml` UI metadata; portable-only bases do not. `.gitignore` must +contain the exact `/.local/` rule. + +The ignored `.local/code-system-graph/` directory contains the runtime binding and an ownership +receipt with hashes for managed skill files. Both record the generating package version, exact +executable fingerprint, and build-time source commit/dirty state when available. Older owned +bindings without this additive provenance remain readable and replaceable. Existing-plugin mode +reports `changed: false` for an identical integration. `--replace-generated` atomically replaces +only recognized local state and never adopts an unmanaged directory. Updating a changed versioned +skill requires `plugin uninstall` followed by `plugin create`. Uninstall requires that receipt and +removes only the +exact managed MCP entries, unchanged skill, and local directory. Modified or unowned components +produce a conflict. `csgraph mcp --binding ` validates the binding and its referenced +manifest, database, and optional CodeGraph executable before starting the read-only server. + ## Intelligence ```text diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c0ed4f4..f37fc7e 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -57,6 +57,7 @@ repos: - "**/generated/**" includeDefaults: - vendor/internal-sdk/** + useGitignore: true httpConsumers: - method: POST path: /orders @@ -71,6 +72,7 @@ repos: | `implementations` | A contract implementation anchor cannot be linked exactly from source | | `excludes` | Additional repository-relative paths must be omitted from automatic discovery | | `includeDefaults` | A specific path inside a default dependency or build exclusion must be discovered | +| `useGitignore` | Repository-contained `.gitignore` rules should filter automatic discovery | These fields add explicit evidence. They are not required for supported, unambiguous source patterns. @@ -102,6 +104,13 @@ match still wins. Explicit `openapi`, `httpConsumers`, `integrationTests`, and ` artifacts remain authoritative and observable even when their containing tree is excluded from automatic discovery. +`useGitignore` defaults to `false`, preserving the 1.0.2 discovery behavior. When enabled, root and +nested `.gitignore` files inside the registered checkout use Git-compatible comments, escapes, and +negations. It deliberately does not load `.ignore`, parent-directory rules, global Git excludes, or +`.git/info/exclude`, and it never follows symlinks. Protected and explicit `excludes` rules still +win; a `.gitignore` negation can only reverse another `.gitignore` rule. Read or parse failures are +reported instead of becoming silent exclusions. + Inspect the complete effective policy, including rules not present in YAML, without creating or opening a database: @@ -124,6 +133,7 @@ version: 1 openapi: ./contracts/openapi.yaml excludes: - coverage/** +useGitignore: true ``` Effective values use this precedence, from highest to lowest: @@ -290,6 +300,7 @@ executionPolicy: maxScanWallTimeMs: 21600000 maxNoProgressTimeMs: 300000 maxCodeGraphSyncWallTimeMsPerRepo: 3600000 + maxCodeGraphCorroborationAnchorsPerRepo: 50 maxWorkerMemoryBytes: 17179869184 gracefulTerminationMs: 5000 watchIdleTimeoutMs: 28800000 @@ -304,7 +315,11 @@ The cache is not preallocated. Completed batches and a fully validated candidate resumed from the owner-only operational sidecar; that candidate remains invisible to every query surface until one atomic publication transaction succeeds. -All values must be positive and representable. The no-progress and per-repository CodeGraph +All values must be positive and representable except +`maxCodeGraphCorroborationAnchorsPerRepo`, which also accepts `-1` for unlimited. Its default is +`50`; it is applied independently after deterministic sorting and deduplication for each +repository. Unlimited corroboration remains subject to scan/provider time, memory, and request +budgets. The independent changed-file ceiling remains 1,024. The no-progress and per-repository CodeGraph deadlines cannot exceed the pass deadline; the termination grace cannot exceed the no-progress deadline; and watcher sub-deadlines cannot exceed the session deadline. Raising values explicitly authorizes greater maximum CPU, memory, or cloud-agent cost. Like extraction budgets, this block is diff --git a/docs/HOOKS.md b/docs/HOOKS.md index 1d94b21..e3c3983 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -1,11 +1,24 @@ # Host Hooks This is the technical lifecycle reference. Most users should start with -[Connect a coding agent](AGENT_SETUP.md#install-optional-routing). +[Connect a coding agent](AGENT_SETUP.md#optional-native-activation). Code System Graph host integration is optional. Installation never runs a scan, initializes CodeGraph, reads source, or enables a network provider. +All editable agent integration content lives under +`crates/code-system-graph-hooks/agent-integration-template/`: + +```text +agent-integration-template/ +├── agent-plugin/ # Agent Plugins 1.0 package and canonical SKILL.md +└── native-hooks/ # signals, selector guidance, fallback rules, gate, and host text +``` + +The Agent Plugin skill is the only detailed MCP procedure. Native templates select that procedure +or provide a compatibility fallback; they do not copy it. Rust owns protocol-safe merging, +validation, markers, quoting, and atomic writes, but no editable routing prose or shell body. + ## Lifecycle ```text @@ -15,9 +28,11 @@ csgraph hooks uninstall --host --workspace --repository [- ``` Supported host identifiers are `claude-code`, `codex`, `gemini`, `antigravity`, and `cursor`. -Claude Code, Codex, and Gemini receive marker-owned JSON hook entries. Antigravity and Cursor -receive marker-owned routing guidance because their supported contracts do not expose the same -prompt event. +Claude Code, Codex, and Gemini receive marker-owned JSON hook entries. Their output selects the +installed Code System Graph skill when available; the hook is not a second detailed procedure. +Antigravity and Cursor receive marker-owned static routing guidance because their supported +contracts do not expose the same prompt event. Do not install those static rules when the packaged +skill is already available. Pass `--codegraph` only when the MCP server for that agent also uses `--codegraph` and advertises `explore`. The hook cannot inspect another process's MCP tool list. Omit the flag for both commands @@ -45,6 +60,12 @@ Prompt text is never persisted or repeated in output. A hash of host, root, and for a bounded TTL to suppress duplicate guidance. Malformed or oversized advisory events fail open with neutral host output. +Dynamic classification uses case-insensitive literal phrases, not model inference. The packaged +signals cover specific English and Spanish graph intents; prompts in other languages may receive +no dynamic guidance. This does not prevent explicit skill use, MCP use, or static fallback rules. +Keep signal phrases narrow: a programming context alone does not make generic fragments such as +`where is`, `dónde`, `code`, or `test` safe selectors. + ## Strict mode `hooks install --strict` additionally installs a marker-owned Git pre-commit block. It invokes diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index bb9ed03..1ddc419 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -5,7 +5,7 @@ for maintainers are in [Release engineering](RELEASE.md). ## Current availability -Code System Graph `1.0.2` is published on [crates.io](https://crates.io/crates/code-system-graph) +Code System Graph `1.0.3` is published on [crates.io](https://crates.io/crates/code-system-graph) and [GitHub Releases](https://github.com/dertin/code-system-graph/releases). Native release CI validates Linux x86_64/ARM64, macOS x86_64/ARM64, and Windows x86_64 before their archives are published. @@ -84,8 +84,8 @@ For a Linux x86_64 archive: ```bash sha256sum --ignore-missing --check SHA256SUMS -tar -xzf code-system-graph-x86_64-unknown-linux-gnu-v1.0.2.tgz -PREFIX="$HOME/.local" ./code-system-graph-x86_64-unknown-linux-gnu-v1.0.2/install.sh +tar -xzf code-system-graph-x86_64-unknown-linux-gnu-v1.0.3.tgz +PREFIX="$HOME/.local" ./code-system-graph-x86_64-unknown-linux-gnu-v1.0.3/install.sh ``` Replace the target in the archive name with `x86_64-apple-darwin`, @@ -93,7 +93,7 @@ Replace the target in the archive name with `x86_64-apple-darwin`, same installer. The Windows archive is a ZIP file. Verify `SHA256SUMS`, extract -`code-system-graph-x86_64-pc-windows-msvc-v1.0.2.zip`, and add its `bin` directory containing +`code-system-graph-x86_64-pc-windows-msvc-v1.0.3.zip`, and add its `bin` directory containing `csgraph.exe` and `code-system-graph-hooks.exe` to `PATH`. `PREFIX` defaults to `$HOME/.local`. The installer places binaries under `$PREFIX/bin`, installed @@ -171,7 +171,7 @@ cargo uninstall code-system-graph-hooks Run `uninstall.sh` from the verified extracted package with the same prefix: ```bash -PREFIX="$HOME/.local" ./code-system-graph-x86_64-unknown-linux-gnu-v1.0.2/uninstall.sh +PREFIX="$HOME/.local" ./code-system-graph-x86_64-unknown-linux-gnu-v1.0.3/uninstall.sh ``` Before uninstalling either installation type, remove any optional agent hooks: diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 3d11b6e..58152ea 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,10 +1,9 @@ -# Code System Graph 1.0.2 Release +# Code System Graph 1.0.3 Release -Code System Graph 1.0.2 is a performance and reliability maintenance release. It avoids -republishing unchanged CodeGraph-backed snapshots, bounds focused corroboration work, preserves -ambiguous HTTP evidence as degradations, and improves supervised-worker diagnostics. The source -tree and package version are `1.0.2`. Continuous integration runs on GitHub at -`https://github.com/dertin/code-system-graph`. +Code System Graph 1.0.3 adds configurable CodeGraph corroboration bounds, opt-in Git-native ignore +semantics, portable workspace-bound Agent Plugins, and ignored local workspace bindings for +existing portable plugins. The source tree and package version are `1.0.3`. Continuous integration +runs on GitHub at `https://github.com/dertin/code-system-graph`. Platform claims below require native build, test, packaging, and archive-smoke evidence from the release workflow. @@ -33,6 +32,9 @@ Code System Graph 1.0.x includes: - deterministic linking, exact manual links and suppressions, immutable snapshots, and freshness; - bounded search, trace, community analysis, compatibility, impact, and change analysis; - opt-in CodeGraph integration through public MCP or CLI contracts; +- configurable per-repository corroboration bounds and opt-in Git-native ignore discovery; +- portable Agent Plugins 1.0.0 generation and ownership-checked local bindings for versioned base + plugins with a read-only MCP and existing routing guidance; - CLI, read-only MCP stdio, optional authenticated HTTP, exports, diagnostics, and host hooks; - exact-schema SQLite backup, restore, and integrity validation; - deterministic Linux package archives, CycloneDX SBOMs, and SHA-256 checksums. @@ -83,13 +85,14 @@ GNU tar, and SHA-256 tooling. The workspace MSRV remains 1.97.1 and is validated ```text SOURCE_DATE_EPOCH=0 scripts/package-release.sh x86_64-unknown-linux-gnu -scripts/smoke-install.sh dist/code-system-graph-x86_64-unknown-linux-gnu-v1.0.2 -sha256sum --check dist/code-system-graph-x86_64-unknown-linux-gnu-v1.0.2.sha256 +scripts/smoke-install.sh dist/code-system-graph-x86_64-unknown-linux-gnu-v1.0.3 +sha256sum --check dist/code-system-graph-x86_64-unknown-linux-gnu-v1.0.3.sha256 ``` -The package contains `csgraph`, `code-system-graph-hooks`, public documentation, license and notice files, -and install/uninstall scripts. The packaging command also emits a CycloneDX JSON SBOM and a -checksum file covering the archive and SBOM. +The package contains `csgraph`, `code-system-graph-hooks`, the visible Agent integration template +tree (portable plugin plus native adapters), public documentation, license and notice files, and +install/uninstall scripts. The packaging command also emits a CycloneDX JSON SBOM and a checksum +file covering the archive and SBOM. Both binary crates declare `cargo-binstall` metadata for this archive layout. The configuration accepts only the official release archive and disables both QuickInstall and source-compilation @@ -117,7 +120,7 @@ clean `main` branch aligned with `origin/main`, Cargo credentials for crates.io, selects `prepare`: ```text -.github/workflows/release.sh 1.0.2 prepare +.github/workflows/release.sh 1.0.3 prepare ``` Preparation runs the complete publish-readiness suite and dry-runs all five packages without @@ -125,7 +128,7 @@ creating a tag, publishing a crate, or dispatching a workflow. To perform the ir pass `publish` explicitly: ```text -.github/workflows/release.sh 1.0.2 publish +.github/workflows/release.sh 1.0.3 publish ``` Publish mode verifies that the workspace repository matches `origin`, creates and pushes the diff --git a/docs/adr/0002-rust-architecture.md b/docs/adr/0002-rust-architecture.md index 98d1868..19d81a2 100644 --- a/docs/adr/0002-rust-architecture.md +++ b/docs/adr/0002-rust-architecture.md @@ -28,7 +28,10 @@ The initial slice starts with only crates that enforce a real boundary: - `code-system-graph-store-sqlite`: exact-schema initialization, fail-closed validation, and transactional persistence. - `code-system-graph-core`: strict manifest loading, HTTP boundary extraction, deterministic linking, trace application service, and provider ports. -- `code-system-graph-cli`: process entry point and delivery adapters, including MCP stdio. +- `code-system-graph-hooks`: shared agent integration lifecycle plus the visible + `agent-integration-template/` tree consumed by native adapters and portable plugin generation. +- `code-system-graph-cli`: process entry point and delivery adapters, including MCP stdio. It + consumes the shared agent templates instead of owning another skill or routing-policy copy. Crates split further only when extractor, query, provider, or interface boundaries have enough behavior to justify independent compilation and ownership. Library errors are typed with diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index dba4152..3fc688f 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -12,8 +12,8 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "0.4.13" -code-system-graph-core = { version = "1.0.2", path = "../crates/code-system-graph-core" } -code-system-graph-model = { version = "1.0.2", path = "../crates/code-system-graph-model" } +code-system-graph-core = { version = "1.0.3", path = "../crates/code-system-graph-core" } +code-system-graph-model = { version = "1.0.3", path = "../crates/code-system-graph-model" } serde_json = "1.0.151" [[bin]] diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 8ee18c2..40f6034 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -15,7 +15,7 @@ fi mkdir -p "$DIST" rm -rf "$STAGE" -mkdir -p "$STAGE/bin" "$STAGE/share/doc/code-system-graph" +mkdir -p "$STAGE/bin" "$STAGE/share/doc/code-system-graph" "$STAGE/share/code-system-graph" CARGO_TARGET_DIR="$TARGET_DIR" cargo build \ --manifest-path "$ROOT/Cargo.toml" \ @@ -30,6 +30,7 @@ install -m 0755 "$TARGET_DIR/$TARGET/release/code-system-graph-hooks$EXE_SUFFIX" install -m 0644 "$ROOT/LICENSE" "$ROOT/NOTICE" "$ROOT/THIRD_PARTY_NOTICES.md" \ "$ROOT/README.md" "$ROOT/SECURITY.md" "$STAGE/share/doc/code-system-graph/" cp -R "$ROOT/docs" "$STAGE/share/doc/code-system-graph/" +cp -R "$ROOT/crates/code-system-graph-hooks/agent-integration-template" "$STAGE/share/code-system-graph/" install -m 0755 "$ROOT/scripts/install.sh" "$ROOT/scripts/uninstall.sh" "$STAGE/" SBOM="$DIST/$NAME.cdx.json" diff --git a/scripts/smoke-install.sh b/scripts/smoke-install.sh index c9ab816..bc4f837 100755 --- a/scripts/smoke-install.sh +++ b/scripts/smoke-install.sh @@ -21,6 +21,61 @@ SECOND="$("$PREFIX/bin/csgraph" --version)" [[ "$SECOND" == "$FIRST" ]] [[ -x "$PREFIX/bin/code-system-graph-hooks" ]] [[ -f "$PREFIX/share/code-system-graph/install-manifest-v1.txt" ]] +TEMPLATE_ROOT="$SOURCE/share/code-system-graph/agent-integration-template" +[[ -f "$TEMPLATE_ROOT/README.md" ]] +[[ -f "$TEMPLATE_ROOT/agent-plugin/plugin.json" ]] +[[ -f "$TEMPLATE_ROOT/agent-plugin/mcp.json" ]] +[[ -f "$TEMPLATE_ROOT/agent-plugin/generated.gitignore" ]] +[[ -f "$TEMPLATE_ROOT/agent-plugin/metadata/skill-description.txt" ]] +[[ -f "$TEMPLATE_ROOT/agent-plugin/metadata/openai-display-name.txt" ]] +[[ -f "$TEMPLATE_ROOT/agent-plugin/metadata/openai-default-prompt.txt" ]] +[[ -f "$TEMPLATE_ROOT/agent-plugin/skills/code-system-graph/SKILL.md" ]] +[[ -f "$TEMPLATE_ROOT/agent-plugin/skills/code-system-graph/references/operating-guide.md" ]] +[[ -f "$TEMPLATE_ROOT/native-hooks/strict-gate.sh" ]] + +SMOKE_WORKSPACE="$PREFIX/plugin-smoke" +mkdir -p "$SMOKE_WORKSPACE/repo/src" +printf '%s\n' 'pub fn smoke() {}' > "$SMOKE_WORKSPACE/repo/src/lib.rs" +printf '%s\n' 'version: 1' 'name: release-plugin-smoke' 'repos:' ' app:' ' path: repo' \ + > "$SMOKE_WORKSPACE/code-system-graph.yaml" +"$PREFIX/bin/csgraph" scan \ + --config "$SMOKE_WORKSPACE/code-system-graph.yaml" \ + --database "$SMOKE_WORKSPACE/graph.db" > /dev/null +"$PREFIX/bin/csgraph" plugin create \ + --output "$SMOKE_WORKSPACE/plugin" \ + --config "$SMOKE_WORKSPACE/code-system-graph.yaml" \ + --database "$SMOKE_WORKSPACE/graph.db" > "$SMOKE_WORKSPACE/plugin-report.json" +[[ -f "$SMOKE_WORKSPACE/plugin/plugin.json" ]] +[[ -f "$SMOKE_WORKSPACE/plugin/mcp.json" ]] +[[ -f "$SMOKE_WORKSPACE/plugin/.gitignore" ]] +[[ -f "$SMOKE_WORKSPACE/plugin/.local/code-system-graph/mcp-binding.json" ]] +grep -q '^/.local/$' "$SMOKE_WORKSPACE/plugin/.gitignore" +! grep -E -R -q '\{\{[A-Z0-9_]+\}\}' "$SMOKE_WORKSPACE/plugin" +grep -q '"changed":true' "$SMOKE_WORKSPACE/plugin-report.json" + +BASE_PLUGIN="$SMOKE_WORKSPACE/base-plugin" +mkdir -p "$BASE_PLUGIN/.codex-plugin" "$BASE_PLUGIN/skills/unrelated-skill" +printf '%s\n' '{"name":"release-base","version":"1.0.0","description":"Release smoke base"}' \ + > "$BASE_PLUGIN/plugin.json" +printf '%s\n' '{"mcpServers":{"release-code-system-graph":{"type":"stdio","command":"csgraph","args":["mcp","--binding","${PLUGIN_ROOT}/.local/code-system-graph/mcp-binding.json"],"env":{"CODE_SYSTEM_GRAPH_MCP_ADMIN":"0"}}}}' \ + > "$BASE_PLUGIN/mcp.json" +printf '%s\n' '{"name":"release-base","version":"1.0.0","mcpServers":{"release-code-system-graph":{"type":"stdio","command":"csgraph","args":["mcp","--binding","${PLUGIN_ROOT}/.local/code-system-graph/mcp-binding.json"],"env":{"CODE_SYSTEM_GRAPH_MCP_ADMIN":"0"}}}}' \ + > "$BASE_PLUGIN/.codex-plugin/plugin.json" +printf '%s\n' '/.local/' > "$BASE_PLUGIN/.gitignore" +printf '%s\n' '---' 'name: unrelated-skill' 'description: Unrelated release smoke skill.' '---' \ + > "$BASE_PLUGIN/skills/unrelated-skill/SKILL.md" +"$PREFIX/bin/csgraph" plugin create \ + --output "$BASE_PLUGIN" \ + --mcp-server-name release-code-system-graph \ + --routing-skill release-system-graph \ + --config "$SMOKE_WORKSPACE/code-system-graph.yaml" \ + --database "$SMOKE_WORKSPACE/graph.db" > "$SMOKE_WORKSPACE/existing-plugin-report.json" +[[ -f "$BASE_PLUGIN/.local/code-system-graph/mcp-binding.json" ]] +[[ -f "$BASE_PLUGIN/skills/release-system-graph/SKILL.md" ]] +[[ -f "$BASE_PLUGIN/skills/unrelated-skill/SKILL.md" ]] +grep -q '"workspace": "release-plugin-smoke"' "$BASE_PLUGIN/.local/code-system-graph/mcp-binding.json" +grep -q '"mode":"existing_plugin"' "$SMOKE_WORKSPACE/existing-plugin-report.json" +grep -q '"changed":true' "$SMOKE_WORKSPACE/existing-plugin-report.json" PREFIX="$PREFIX" "$SOURCE/uninstall.sh" [[ ! -e "$PREFIX/bin/csgraph" ]]