From b7f5b16a8d9fbf25ef038afbf42af1609c4a4bec Mon Sep 17 00:00:00 2001 From: Zain Dana Harper <17142659+HarperZ9@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:07:01 -0700 Subject: [PATCH] Vendor the server code into the client plugin folder A directory install receives only client-plugin/, so the launcher could not import index_graph. The folder now carries server/src/index_graph, the same files the source ZIP places there. build_client_package.py --sync-vendored rewrites it from src/, the builder excludes it from plugin inputs so nothing is counted twice, and a test fails on any drift and names the sync command. The launcher drops the fallback to the repository's src and exits 1 with a one-line message when the copy is missing. New tests launch the folder alone with the exact Claude command and check the 512-file and 256 KiB limits. The icon moves to the 512 px PNG, since the 1024 px file is 273 KiB. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + client-plugin/.claude-plugin/icon.png | Bin 280030 -> 101865 bytes client-plugin/README.md | 4 +- client-plugin/server/serve.py | 7 +- .../server/src/index_graph/__init__.py | 18 + .../server/src/index_graph/__main__.py | 4 + .../server/src/index_graph/arch/__init__.py | 10 + .../server/src/index_graph/arch/check.py | 128 ++ .../server/src/index_graph/arch/criteria.py | 65 + .../server/src/index_graph/bench/__init__.py | 4 + .../server/src/index_graph/bench/economy.py | 90 ++ client-plugin/server/src/index_graph/cache.py | 118 ++ .../src/index_graph/certify/__init__.py | 6 + .../src/index_graph/certify/certificate.py | 42 + .../server/src/index_graph/classify.py | 29 + client-plugin/server/src/index_graph/cli.py | 190 +++ .../src/index_graph/cli_handlers/__init__.py | 41 + .../src/index_graph/cli_handlers/_common.py | 79 ++ .../src/index_graph/cli_handlers/certify.py | 228 ++++ .../src/index_graph/cli_handlers/context.py | 248 ++++ .../src/index_graph/cli_handlers/graph.py | 237 ++++ .../src/index_graph/cli_handlers/lsp.py | 16 + .../src/index_graph/cli_handlers/maps.py | 131 ++ .../src/index_graph/cli_handlers/serve.py | 14 + .../src/index_graph/cli_handlers/symbols.py | 106 ++ .../src/index_graph/cli_handlers/verify.py | 235 ++++ .../server/src/index_graph/cli_parser.py | 474 +++++++ .../server/src/index_graph/client_mcp.py | 184 +++ .../server/src/index_graph/client_state.py | 56 + .../server/src/index_graph/config.py | 157 +++ .../src/index_graph/context/__init__.py | 1 + .../src/index_graph/context/envelope.py | 677 ++++++++++ .../server/src/index_graph/context/focus.py | 78 ++ .../server/src/index_graph/context/lens.py | 120 ++ .../server/src/index_graph/context/pack.py | 138 +++ .../server/src/index_graph/context/select.py | 251 ++++ .../server/src/index_graph/drift/__init__.py | 7 + .../server/src/index_graph/drift/diff.py | 65 + .../server/src/index_graph/drift/snapshot.py | 30 + .../server/src/index_graph/flagship.py | 160 +++ .../src/index_graph/freshness/__init__.py | 8 + .../src/index_graph/freshness/compare.py | 33 + .../src/index_graph/freshness/fingerprint.py | 131 ++ .../src/index_graph/freshness/invalidate.py | 273 +++++ .../index_graph/freshness/invalidate_cli.py | 80 ++ .../server/src/index_graph/freshness/watch.py | 118 ++ .../server/src/index_graph/gitmeta.py | 172 +++ .../server/src/index_graph/graph/__init__.py | 16 + .../server/src/index_graph/graph/build.py | 341 ++++++ .../server/src/index_graph/graph/cache.py | 239 ++++ .../src/index_graph/graph/cache_metrics.py | 100 ++ .../server/src/index_graph/graph/cycles.py | 73 ++ .../src/index_graph/graph/description.py | 42 + .../server/src/index_graph/graph/edges.py | 97 ++ .../server/src/index_graph/graph/progress.py | 27 + .../index_graph/graph/resolvers/__init__.py | 25 + .../src/index_graph/graph/resolvers/base.py | 31 + .../src/index_graph/graph/resolvers/cpp.py | 110 ++ .../src/index_graph/graph/resolvers/csharp.py | 77 ++ .../src/index_graph/graph/resolvers/go.py | 87 ++ .../src/index_graph/graph/resolvers/java.py | 92 ++ .../index_graph/graph/resolvers/javascript.py | 67 + .../src/index_graph/graph/resolvers/php.py | 62 + .../src/index_graph/graph/resolvers/python.py | 140 +++ .../src/index_graph/graph/resolvers/ruby.py | 70 ++ .../src/index_graph/graph/resolvers/rust.py | 65 + .../server/src/index_graph/graph/roles.py | 65 + .../server/src/index_graph/graph/walk.py | 317 +++++ .../src/index_graph/internals/__init__.py | 10 + .../server/src/index_graph/internals/build.py | 76 ++ .../src/index_graph/internals/modules.py | 337 +++++ .../server/src/index_graph/interop.py | 90 ++ .../src/index_graph/knowledge/__init__.py | 0 .../server/src/index_graph/knowledge/atlas.py | 168 +++ .../server/src/index_graph/knowledge/docs.py | 92 ++ .../src/index_graph/knowledge/markdown.py | 146 +++ .../server/src/index_graph/lsp/__init__.py | 15 + .../server/src/index_graph/lsp/protocol.py | 93 ++ .../server/src/index_graph/lsp/server.py | 275 +++++ .../server/src/index_graph/lsp/symbols_lsp.py | 123 ++ client-plugin/server/src/index_graph/mcp.py | 730 +++++++++++ client-plugin/server/src/index_graph/model.py | 93 ++ client-plugin/server/src/index_graph/route.py | 250 ++++ .../server/src/index_graph/router.py | 111 ++ .../src/index_graph/router_inventory.py | 310 +++++ .../src/index_graph/router_job_surface.py | 78 ++ .../src/index_graph/router_job_telemetry.py | 50 + .../src/index_graph/router_job_utils.py | 51 + .../server/src/index_graph/router_jobs.py | 1086 +++++++++++++++++ client-plugin/server/src/index_graph/scan.py | 579 +++++++++ .../src/index_graph/symbols/__init__.py | 24 + .../server/src/index_graph/symbols/build.py | 111 ++ .../server/src/index_graph/symbols/calls.py | 249 ++++ .../src/index_graph/symbols/definitions.py | 95 ++ .../server/src/index_graph/symbols/imports.py | 72 ++ .../src/index_graph/symbols/inheritance.py | 183 +++ .../server/src/index_graph/symbols/model.py | 62 + .../src/index_graph/symbols/navigate.py | 89 ++ .../server/src/index_graph/verify.py | 87 ++ .../server/src/index_graph/viz/__init__.py | 16 + .../src/index_graph/viz/atlas_assets.py | 127 ++ .../server/src/index_graph/viz/atlas_html.py | 43 + .../src/index_graph/viz/atlas_layout.py | 103 ++ .../server/src/index_graph/viz/atlas_svg.py | 63 + .../server/src/index_graph/viz/charts.py | 49 + .../server/src/index_graph/viz/html.py | 136 +++ .../server/src/index_graph/viz/layout.py | 215 ++++ .../server/src/index_graph/viz/lens_html.py | 219 ++++ .../server/src/index_graph/viz/manifest.py | 43 + .../server/src/index_graph/viz/mermaid.py | 70 ++ .../server/src/index_graph/viz/svg.py | 73 ++ .../server/src/index_graph/viz/theme.py | 65 + .../src/index_graph/viz/workbench_assets.py | 174 +++ .../src/index_graph/viz/workbench_html.py | 225 ++++ .../src/index_graph/viz/workbench_js.py | 163 +++ .../src/index_graph/viz/workbench_js2.py | 126 ++ .../server/src/index_graph/wiki/__init__.py | 19 + .../server/src/index_graph/wiki/cli.py | 121 ++ .../server/src/index_graph/wiki/html.py | 191 +++ .../server/src/index_graph/wiki/pack.py | 253 ++++ .../server/src/index_graph/wiki/seal.py | 213 ++++ .../server/src/index_graph/wiki/serve.py | 141 +++ .../src/index_graph/wiki/serve_pages.py | 95 ++ .../server/src/index_graph/workbench.py | 291 +++++ scripts/build_client_package.py | 39 +- tests/test_client_vendored.py | 76 ++ 126 files changed, 16488 insertions(+), 8 deletions(-) create mode 100644 client-plugin/server/src/index_graph/__init__.py create mode 100644 client-plugin/server/src/index_graph/__main__.py create mode 100644 client-plugin/server/src/index_graph/arch/__init__.py create mode 100644 client-plugin/server/src/index_graph/arch/check.py create mode 100644 client-plugin/server/src/index_graph/arch/criteria.py create mode 100644 client-plugin/server/src/index_graph/bench/__init__.py create mode 100644 client-plugin/server/src/index_graph/bench/economy.py create mode 100644 client-plugin/server/src/index_graph/cache.py create mode 100644 client-plugin/server/src/index_graph/certify/__init__.py create mode 100644 client-plugin/server/src/index_graph/certify/certificate.py create mode 100644 client-plugin/server/src/index_graph/classify.py create mode 100644 client-plugin/server/src/index_graph/cli.py create mode 100644 client-plugin/server/src/index_graph/cli_handlers/__init__.py create mode 100644 client-plugin/server/src/index_graph/cli_handlers/_common.py create mode 100644 client-plugin/server/src/index_graph/cli_handlers/certify.py create mode 100644 client-plugin/server/src/index_graph/cli_handlers/context.py create mode 100644 client-plugin/server/src/index_graph/cli_handlers/graph.py create mode 100644 client-plugin/server/src/index_graph/cli_handlers/lsp.py create mode 100644 client-plugin/server/src/index_graph/cli_handlers/maps.py create mode 100644 client-plugin/server/src/index_graph/cli_handlers/serve.py create mode 100644 client-plugin/server/src/index_graph/cli_handlers/symbols.py create mode 100644 client-plugin/server/src/index_graph/cli_handlers/verify.py create mode 100644 client-plugin/server/src/index_graph/cli_parser.py create mode 100644 client-plugin/server/src/index_graph/client_mcp.py create mode 100644 client-plugin/server/src/index_graph/client_state.py create mode 100644 client-plugin/server/src/index_graph/config.py create mode 100644 client-plugin/server/src/index_graph/context/__init__.py create mode 100644 client-plugin/server/src/index_graph/context/envelope.py create mode 100644 client-plugin/server/src/index_graph/context/focus.py create mode 100644 client-plugin/server/src/index_graph/context/lens.py create mode 100644 client-plugin/server/src/index_graph/context/pack.py create mode 100644 client-plugin/server/src/index_graph/context/select.py create mode 100644 client-plugin/server/src/index_graph/drift/__init__.py create mode 100644 client-plugin/server/src/index_graph/drift/diff.py create mode 100644 client-plugin/server/src/index_graph/drift/snapshot.py create mode 100644 client-plugin/server/src/index_graph/flagship.py create mode 100644 client-plugin/server/src/index_graph/freshness/__init__.py create mode 100644 client-plugin/server/src/index_graph/freshness/compare.py create mode 100644 client-plugin/server/src/index_graph/freshness/fingerprint.py create mode 100644 client-plugin/server/src/index_graph/freshness/invalidate.py create mode 100644 client-plugin/server/src/index_graph/freshness/invalidate_cli.py create mode 100644 client-plugin/server/src/index_graph/freshness/watch.py create mode 100644 client-plugin/server/src/index_graph/gitmeta.py create mode 100644 client-plugin/server/src/index_graph/graph/__init__.py create mode 100644 client-plugin/server/src/index_graph/graph/build.py create mode 100644 client-plugin/server/src/index_graph/graph/cache.py create mode 100644 client-plugin/server/src/index_graph/graph/cache_metrics.py create mode 100644 client-plugin/server/src/index_graph/graph/cycles.py create mode 100644 client-plugin/server/src/index_graph/graph/description.py create mode 100644 client-plugin/server/src/index_graph/graph/edges.py create mode 100644 client-plugin/server/src/index_graph/graph/progress.py create mode 100644 client-plugin/server/src/index_graph/graph/resolvers/__init__.py create mode 100644 client-plugin/server/src/index_graph/graph/resolvers/base.py create mode 100644 client-plugin/server/src/index_graph/graph/resolvers/cpp.py create mode 100644 client-plugin/server/src/index_graph/graph/resolvers/csharp.py create mode 100644 client-plugin/server/src/index_graph/graph/resolvers/go.py create mode 100644 client-plugin/server/src/index_graph/graph/resolvers/java.py create mode 100644 client-plugin/server/src/index_graph/graph/resolvers/javascript.py create mode 100644 client-plugin/server/src/index_graph/graph/resolvers/php.py create mode 100644 client-plugin/server/src/index_graph/graph/resolvers/python.py create mode 100644 client-plugin/server/src/index_graph/graph/resolvers/ruby.py create mode 100644 client-plugin/server/src/index_graph/graph/resolvers/rust.py create mode 100644 client-plugin/server/src/index_graph/graph/roles.py create mode 100644 client-plugin/server/src/index_graph/graph/walk.py create mode 100644 client-plugin/server/src/index_graph/internals/__init__.py create mode 100644 client-plugin/server/src/index_graph/internals/build.py create mode 100644 client-plugin/server/src/index_graph/internals/modules.py create mode 100644 client-plugin/server/src/index_graph/interop.py create mode 100644 client-plugin/server/src/index_graph/knowledge/__init__.py create mode 100644 client-plugin/server/src/index_graph/knowledge/atlas.py create mode 100644 client-plugin/server/src/index_graph/knowledge/docs.py create mode 100644 client-plugin/server/src/index_graph/knowledge/markdown.py create mode 100644 client-plugin/server/src/index_graph/lsp/__init__.py create mode 100644 client-plugin/server/src/index_graph/lsp/protocol.py create mode 100644 client-plugin/server/src/index_graph/lsp/server.py create mode 100644 client-plugin/server/src/index_graph/lsp/symbols_lsp.py create mode 100644 client-plugin/server/src/index_graph/mcp.py create mode 100644 client-plugin/server/src/index_graph/model.py create mode 100644 client-plugin/server/src/index_graph/route.py create mode 100644 client-plugin/server/src/index_graph/router.py create mode 100644 client-plugin/server/src/index_graph/router_inventory.py create mode 100644 client-plugin/server/src/index_graph/router_job_surface.py create mode 100644 client-plugin/server/src/index_graph/router_job_telemetry.py create mode 100644 client-plugin/server/src/index_graph/router_job_utils.py create mode 100644 client-plugin/server/src/index_graph/router_jobs.py create mode 100644 client-plugin/server/src/index_graph/scan.py create mode 100644 client-plugin/server/src/index_graph/symbols/__init__.py create mode 100644 client-plugin/server/src/index_graph/symbols/build.py create mode 100644 client-plugin/server/src/index_graph/symbols/calls.py create mode 100644 client-plugin/server/src/index_graph/symbols/definitions.py create mode 100644 client-plugin/server/src/index_graph/symbols/imports.py create mode 100644 client-plugin/server/src/index_graph/symbols/inheritance.py create mode 100644 client-plugin/server/src/index_graph/symbols/model.py create mode 100644 client-plugin/server/src/index_graph/symbols/navigate.py create mode 100644 client-plugin/server/src/index_graph/verify.py create mode 100644 client-plugin/server/src/index_graph/viz/__init__.py create mode 100644 client-plugin/server/src/index_graph/viz/atlas_assets.py create mode 100644 client-plugin/server/src/index_graph/viz/atlas_html.py create mode 100644 client-plugin/server/src/index_graph/viz/atlas_layout.py create mode 100644 client-plugin/server/src/index_graph/viz/atlas_svg.py create mode 100644 client-plugin/server/src/index_graph/viz/charts.py create mode 100644 client-plugin/server/src/index_graph/viz/html.py create mode 100644 client-plugin/server/src/index_graph/viz/layout.py create mode 100644 client-plugin/server/src/index_graph/viz/lens_html.py create mode 100644 client-plugin/server/src/index_graph/viz/manifest.py create mode 100644 client-plugin/server/src/index_graph/viz/mermaid.py create mode 100644 client-plugin/server/src/index_graph/viz/svg.py create mode 100644 client-plugin/server/src/index_graph/viz/theme.py create mode 100644 client-plugin/server/src/index_graph/viz/workbench_assets.py create mode 100644 client-plugin/server/src/index_graph/viz/workbench_html.py create mode 100644 client-plugin/server/src/index_graph/viz/workbench_js.py create mode 100644 client-plugin/server/src/index_graph/viz/workbench_js2.py create mode 100644 client-plugin/server/src/index_graph/wiki/__init__.py create mode 100644 client-plugin/server/src/index_graph/wiki/cli.py create mode 100644 client-plugin/server/src/index_graph/wiki/html.py create mode 100644 client-plugin/server/src/index_graph/wiki/pack.py create mode 100644 client-plugin/server/src/index_graph/wiki/seal.py create mode 100644 client-plugin/server/src/index_graph/wiki/serve.py create mode 100644 client-plugin/server/src/index_graph/wiki/serve_pages.py create mode 100644 client-plugin/server/src/index_graph/workbench.py create mode 100644 tests/test_client_vendored.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 646a21e..5582cd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- The plugin folder now carries its own copy of the server code under + `client-plugin/server/src`, so a directory install that receives only that folder + starts. `python scripts/build_client_package.py --sync-vendored` rewrites it from + `src/`, and a test fails when the copy drifts. The launcher no longer falls back to + the repository's `src` and reports a missing copy in one line. +- The Claude plugin icon is the 512 px PNG, which keeps every plugin file under 256 KiB. - The Claude plugin manifest carries directory listing fields: display name, keywords, homepage, documentation, support, privacy and terms links, and a 1024 px icon. - Claude Code now asks for the readable workspace and the optional state directory diff --git a/client-plugin/.claude-plugin/icon.png b/client-plugin/.claude-plugin/icon.png index 714727338a53bd83342971aed5e1a9a6183f8243..b13186fe924af0f879dbf3afa84f08c18f76a24b 100644 GIT binary patch literal 101865 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%0A@Ac}vj$C1~ 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 P00000NkvXXu0mjfV~!A2 literal 280030 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

Nrsc}i4xkU}LFT!Z=yqTnaboEi#C?XJ z_|s4vL0z{J$2$_Ph!8JF1qAt?p}en-PfmQV-@^?39-JsKP1Hn>uph0Nnw&e)H4)lS zNQ>*NcJhMFAZrUN55NA4!1}!DcJwd*pJ9*7O$c5d#6@00upGg+ILBHNvS1uKcdVZq z(i+ep^tb}nsEG=PHT_zg%E`qJ8p~Ax-o$}J7&DiJahTGWO93zS0YwVasZaB8W*@a(J5G2ETkQ3sm>RaEk@nyR8AOFS`f;x+ukdSBsKOstm%h|V&)rYk@7D(i z60j=c*ncA#aoLqKS|Ik|f3*qnS%z~A{Ju%wxPaAccnsz|UA+;*_B8k4f!|wV1W)a5 ztJmoSK9T~wDwm;!XF)*lOHMJwV!Je0cA1cwnY+U z5i;u&_U7eBaWtb4{RwP3*KFIjA@6#`wDqBT#PJ?v`%lu!8vffQ4 zgrJ(G>q;(_uptg?YiCZ+=KIt`i69uFjG3P6#Y2WP{I$MU$q+M$d3^4>i#CJ}xXD_1 z7kKuJrcW#x4TCcmkF1md8yEDSFDREO8^ufYZ0_Bd;e<56R2ACWOxd7(q6li~q>jgJXRQMrDTb|n%KRXOyLhMECh^tIe zW-)8JQd8>$-oW81Fo=UUi1DyJncK=O=)lWSCBLmzym+-3kY^(O z8^)3n!dMFUGWs)2L;y(0z1}9I0kgI=CjB1ECqH<2_zwppDhW-f;shDevnCqp8Y&?^ z#d#^Iy*ew>m-da5B`8WftQvm#paF8eR;BC!6P7mniP)v8<)Pp8mbw1q87h6x!Jlzw z>IlR@LtKW8Bu~%X(O?x20aO^Z9&;cvST2>}xU?4n{LMXAf78TT1HPJRSVvWhF`UVB zzXvwbPPP7zQ!)ie#tsz!W{o4W#)WnIb$!^Ktn3cyA%cn{&nstHnJojGAmh<}MJ{q#LDP4}9ev#Y(ko5s?T1oEZvaL7K+KmXk^dvFnJTd~xVb zJ|UQ*QooH$Ves>NYVUze->I7p4f9d9MuWq;niVEB@i9Kszo0OO-W3yNXE9qeNQMlV z4Uq*{rB`WdVi3Rc`RW(tH%->hHIuRx37<6oa-!&m0v-Z1RAhMt`JUT#7gIo@JRMv00Ww}8; zw39ze5QxN*sekI!6rO_M!?fx{fp#!B_vFNyI74+U#OLtsX83nCQDQPNm^m&>zBTSw zmQ}`U-bbdNKldN?0wvNgO?;BpxI-<4H9wew%0Wp3p0=y$p7(c_t=Ge}SiZbe0<-eh z#X_50C08Cw4@@9=f<&9L(3;R@IV4&tmvH@1>WbJ!9ju2&)CSe`F;`E>LhNTCwd@4k zkSqo$44b8MecP%@d=~l_Cg|_klH= zRbe4joC?ywhn@(rlmAk*bqgvMRs!=>z>T6_LRcATJg3;07*vv&71=%g?#_svZ=

  • Qk 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

      S6 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

      Lm`j(>N^NWL){IzoMcaflsCms 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 BTUh`A diff --git a/client-plugin/README.md b/client-plugin/README.md index d0158c8..53ce98d 100644 --- a/client-plugin/README.md +++ b/client-plugin/README.md @@ -22,7 +22,9 @@ private state directory** that you can leave empty for read-only access. The Claude manifest passes these values as `${user_config.*}` launch arguments. Portable and Codex manifests keep the REPLACE_WITH_ABSOLUTE_WORKSPACE placeholder; replace it with the directory you want the client to read, and -select your installed Python executable. Keep the complete extracted bundle. The Windows x64 binary MCPB and ZIP include Python; +select your installed Python executable. The plugin folder carries its own copy +of the server code under `server/src`, so keep the complete folder or extracted +bundle together. The Windows x64 binary MCPB and ZIP include Python; open the MCPB in a compatible desktop client and choose a workspace directory, or configure the ZIP's server executable with --workspace ABSOLUTE_DIRECTORY. No model, API key, hosting account, automatic client configuration, or publisher diff --git a/client-plugin/server/serve.py b/client-plugin/server/serve.py index 47bc878..8af291c 100644 --- a/client-plugin/server/serve.py +++ b/client-plugin/server/serve.py @@ -2,9 +2,10 @@ from pathlib import Path import sys base = Path(__file__).resolve().parents[1] -source = base / "server/src" -if not source.is_dir(): - source = base.parent / "src" +source = base / "server" / "src" +if not (source / "index_graph" / "client_mcp.py").is_file(): + sys.stderr.write("index: the server code is missing from the plugin folder. Reinstall the plugin.\n") + raise SystemExit(1) sys.path.insert(0, str(source)) from index_graph.client_mcp import main if __name__ == "__main__": diff --git a/client-plugin/server/src/index_graph/__init__.py b/client-plugin/server/src/index_graph/__init__.py new file mode 100644 index 0000000..5db91ad --- /dev/null +++ b/client-plugin/server/src/index_graph/__init__.py @@ -0,0 +1,18 @@ +"""Compact JSON repository inventory maps for multi-repo workspaces.""" + +from __future__ import annotations + +__version__ = "2.15.0" + +from .classify import classify +from .config import Config, Rule, default_config, load_config +from .model import SCHEMA_VERSION, Map, RepoRow +from .route import build_route +from .scan import build_map, discover_repos, write_map + +__all__ = [ + "build_map", "write_map", "discover_repos", + "Map", "RepoRow", "SCHEMA_VERSION", + "Config", "Rule", "load_config", "default_config", + "classify", "build_route", "__version__", +] diff --git a/client-plugin/server/src/index_graph/__main__.py b/client-plugin/server/src/index_graph/__main__.py new file mode 100644 index 0000000..bfdcd0c --- /dev/null +++ b/client-plugin/server/src/index_graph/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/client-plugin/server/src/index_graph/arch/__init__.py b/client-plugin/server/src/index_graph/arch/__init__.py new file mode 100644 index 0000000..d83b6cf --- /dev/null +++ b/client-plugin/server/src/index_graph/arch/__init__.py @@ -0,0 +1,10 @@ +"""Architecture criteria and the check that measures a graph against them.""" +from __future__ import annotations + +from .criteria import ArchitectureCriteria, ForbidRule, parse_architecture +from .check import Finding, check_graph + +__all__ = [ + "ArchitectureCriteria", "ForbidRule", "parse_architecture", + "Finding", "check_graph", +] diff --git a/client-plugin/server/src/index_graph/arch/check.py b/client-plugin/server/src/index_graph/arch/check.py new file mode 100644 index 0000000..7c8e111 --- /dev/null +++ b/client-plugin/server/src/index_graph/arch/check.py @@ -0,0 +1,128 @@ +"""Measure a graph against an ArchitectureCriteria; produce evidence-bearing findings. + +`glob_to_regex` is imported lazily inside the matcher so this module can be +imported from config.py without a circular import. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +from .criteria import ArchitectureCriteria + + +@dataclass(frozen=True) +class Finding: + rule: str + detail: str + edge: str | None + evidence: str | None + + +def _match(glob: str, name: str) -> bool: + from ..config import glob_to_regex + return re.match(glob_to_regex(glob), name) is not None + + +def _first_evidence(rel: dict) -> str | None: + sigs = rel.get("signals") or [] + if not sigs: + return None + s = sigs[0] + f = s.get("file") + if not f: + return None + line = s.get("line") + return f"{f}:{line}" if line is not None else f + + +def _layer_of(name: str, layers: tuple[str, ...]) -> int | None: + for i, layer in enumerate(layers): + if (name == layer or name.startswith(layer + "/") + or _match(f"{layer}/**", name) or _match(f"**/{layer}", name)): + return i + return None + + +def check_graph(pack: dict, criteria: ArchitectureCriteria) -> list[Finding]: + findings: list[Finding] = [] + relations = [r for r in pack.get("relations", []) if not r.get("external")] + + # forbidden edges + for rule in criteria.forbid: + for r in relations: + frm, to = r.get("from"), r.get("to") + if to and _match(rule.from_glob, frm) and _match(rule.to_glob, to): + findings.append(Finding( + "forbid", f"{rule.from_glob} must not depend on {rule.to_glob}", + f"{frm} -> {to}", _first_evidence(r))) + + # layers: lower index = lower layer; an edge from lower to higher is a violation + if criteria.layers: + for r in relations: + frm, to = r.get("from"), r.get("to") + if not to: + continue + li, lj = _layer_of(frm, criteria.layers), _layer_of(to, criteria.layers) + if li is not None and lj is not None and li < lj: + findings.append(Finding( + "layer", + f"{criteria.layers[li]} must not depend upward on {criteria.layers[lj]}", + f"{frm} -> {to}", _first_evidence(r))) + + # cycle ceiling + if criteria.max_cycles is not None: + n = len(pack.get("cycles", [])) + if n > criteria.max_cycles: + findings.append(Finding( + "max_cycles", + f"{n} dependency cycle(s) exceed the ceiling of {criteria.max_cycles}", + None, None)) + + # repo names present in the workspace (used by forbid, owns, require checks) + names: list[str] = [] + if criteria.owns or criteria.require or criteria.forbid: + names_src = [r.get("from") for r in pack.get("relations", [])] + names_src += [r.get("to") for r in pack.get("relations", []) if r.get("to")] + names_src += list(pack.get("roles", {}).keys()) + names = sorted({n for n in names_src if n}) + + # a forbid rule whose endpoints name no repo cannot be meaningfully checked: + # the forbidden edge is trivially absent because a glob is wrong, not + # because the code obeys the rule. Mirror require/owns: emit an UNVERIFIABLE + # criterion-quality finding, never a silent vacuous pass. + for rule in criteria.forbid: + if not (any(_match(rule.from_glob, n) for n in names) + and any(_match(rule.to_glob, n) for n in names)): + findings.append(Finding( + "forbid_unmatched", + f"forbid {rule.from_glob} -> {rule.to_glob} names a repo not in the workspace", + None, None)) + + # ownership: a declared owner glob that matches no repo is a finding + for glob, owner in criteria.owns: + if not any(_match(glob, n) for n in names): + findings.append(Finding( + "owns", f"ownership glob {glob} ({owner}) matches no repo", None, None)) + + # required edges (Reflexion conformance). An intended dependency between repos that + # both exist but are not connected is an 'absence' (a confirmed breach -> DRIFT). A + # require rule whose endpoints are not in the workspace at all is 'require_unmatched', + # a criterion-quality gap that reads UNVERIFIABLE, mirroring an unmatched layer. + for rule in criteria.require: + if not (any(_match(rule.from_glob, n) for n in names) + and any(_match(rule.to_glob, n) for n in names)): + findings.append(Finding( + "require_unmatched", + f"require {rule.from_glob} -> {rule.to_glob} names a repo not in the workspace", + None, None)) + continue + present = any( + r.get("to") and _match(rule.from_glob, r.get("from")) and _match(rule.to_glob, r.get("to")) + for r in relations) + if not present: + findings.append(Finding( + "absence", f"{rule.from_glob} should depend on {rule.to_glob} but does not", + None, None)) + + return sorted(findings, key=lambda f: (f.rule, f.edge or "", f.detail)) diff --git a/client-plugin/server/src/index_graph/arch/criteria.py b/client-plugin/server/src/index_graph/arch/criteria.py new file mode 100644 index 0000000..2746d15 --- /dev/null +++ b/client-plugin/server/src/index_graph/arch/criteria.py @@ -0,0 +1,65 @@ +"""The architecture criterion: a rule the graph is measured against. + +This module imports nothing from the rest of the package so that config.py can +import it without a cycle. The check that consumes a criterion lives in check.py. +""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ForbidRule: + from_glob: str + to_glob: str + + +@dataclass(frozen=True) +class RequireRule: + """An intended dependency that must be realized. A missing one is an + 'absence' in Reflexion-model conformance (the architecture you declared is + not the one the code actually built).""" + from_glob: str + to_glob: str + + +@dataclass(frozen=True) +class ArchitectureCriteria: + layers: tuple[str, ...] = () + forbid: tuple[ForbidRule, ...] = () + max_cycles: int | None = None + owns: tuple[tuple[str, str], ...] = () + require: tuple[RequireRule, ...] = () + + @property + def declared(self) -> bool: + return bool(self.layers or self.forbid or self.require + or self.max_cycles is not None or self.owns) + + +def parse_architecture(data: dict) -> ArchitectureCriteria: + """Parse the [architecture] TOML block. Raises SystemExit on malformed input.""" + layers = tuple(str(x) for x in data.get("layers", [])) + + forbid: list[ForbidRule] = [] + for idx, item in enumerate(data.get("forbid", [])): + if not isinstance(item, dict) or "from" not in item or "to" not in item: + raise SystemExit(f"[architecture] forbid[{idx}] requires 'from' and 'to'") + forbid.append(ForbidRule(str(item["from"]), str(item["to"]))) + + require: list[RequireRule] = [] + for idx, item in enumerate(data.get("require", [])): + if not isinstance(item, dict) or "from" not in item or "to" not in item: + raise SystemExit(f"[architecture] require[{idx}] requires 'from' and 'to'") + require.append(RequireRule(str(item["from"]), str(item["to"]))) + + mc = data.get("max_cycles", None) + if mc is not None and (isinstance(mc, bool) or not isinstance(mc, int) or mc < 0): + raise SystemExit("[architecture] max_cycles must be a non-negative integer") + + owns_raw = data.get("owns", {}) + if not isinstance(owns_raw, dict): + raise SystemExit("[architecture] owns must be a table of glob = owner") + owns = tuple(sorted((str(k), str(v)) for k, v in owns_raw.items())) + + return ArchitectureCriteria(layers, tuple(forbid), mc, owns, tuple(require)) diff --git a/client-plugin/server/src/index_graph/bench/__init__.py b/client-plugin/server/src/index_graph/bench/__init__.py new file mode 100644 index 0000000..71095fd --- /dev/null +++ b/client-plugin/server/src/index_graph/bench/__init__.py @@ -0,0 +1,4 @@ +"""Token economy: index's structural pack vs the source it reads (the no-eval-gap number).""" +from .economy import SCHEMA, bench_workspace + +__all__ = ["SCHEMA", "bench_workspace"] diff --git a/client-plugin/server/src/index_graph/bench/economy.py b/client-plugin/server/src/index_graph/bench/economy.py new file mode 100644 index 0000000..798eba1 --- /dev/null +++ b/client-plugin/server/src/index_graph/bench/economy.py @@ -0,0 +1,90 @@ +"""Token economy: how much smaller is index's structural pack than the source it reads. + +The thesis the research converges on (a structural map is cheaper than letting an +agent read files) is here turned into a number anyone can reproduce on their own +workspace. index reads the manifests and sources of every ecosystem to build the +graph; it emits one compact structural pack. This measures the ratio between the two. + +Bytes are exact and model-agnostic. The token figures use the common ~4 bytes/token +approximation, but the reduction RATIO is independent of that constant (it divides out), +so the headline number does not depend on any tokenizer. +""" +from __future__ import annotations + +import json +from pathlib import Path + +from ..context.pack import to_json +from ..freshness.fingerprint import relevant_files +from ..graph.build import build_graph + +SCHEMA = "index.bench/1" +BYTES_PER_TOKEN = 4 # common ~4 bytes/token approximation; the reduction ratio does not depend on it + + +def _source_bytes(repo_paths: dict[str, Path]) -> tuple[int, int]: + """Total bytes and file count of the graph-relevant files index reads.""" + total = files = 0 + for root in repo_paths.values(): + for p in relevant_files(root): + try: + total += p.stat().st_size + except OSError: + continue + files += 1 + return total, files + + +def _compact_bytes(obj) -> int: + return len(json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8")) + + +def _grounding(pack: dict) -> dict: + """Faithfulness of the reduction: what fraction of the internal dependency + edges the compact pack KEEPS are backed by real file:line source evidence. + + A byte reduction is only honest if it fabricates nothing. grep-and-truncate + or an LLM summary can drop or invent structure; index's every retained edge + cites the import that produced it. This turns 'the reduction is faithful' + into a measured number, not a promise: grounded internal edges / internal + edges. 1.0 means every structural fact kept is provably in the source.""" + internal = [r for r in pack.get("relations", []) if not r.get("external")] + grounded = [r for r in internal + if any(s.get("file") for s in r.get("signals", []))] + total = len(internal) + return { + "internal_edges": total, + "grounded_edges": len(grounded), + # a workspace with no internal edges grounded NOTHING: report the honest + # null, not a vacuous 1.0 that reads as perfect faithfulness for a + # reduction that had no structure to fabricate or preserve + "edge_grounding": (round(len(grounded) / total, 4) if total else None), + "note": ("fraction of kept dependency edges carrying file:line evidence; " + "1.0 = the reduction fabricates no structure; null = no " + "internal edges to ground"), + } + + +def bench_workspace(repo_paths: dict[str, Path], *, use_graph_cache: bool = True) -> dict: + """The bytes index reads vs the bytes of the structural pack it emits, AND + the faithfulness of that reduction (every kept edge grounded in source). + + Deterministic for a fixed workspace, so the report is re-checkable like every + other index verdict. + """ + src_bytes, n_files = _source_bytes(repo_paths) + pack = to_json(build_graph(repo_paths, use_cache=use_graph_cache)) + pack_bytes = _compact_bytes(pack) + reduction = round(src_bytes / pack_bytes, 1) if pack_bytes else None + return { + "schema": SCHEMA, + "repos": len(repo_paths), + "source_files": n_files, + "source_bytes": src_bytes, + "pack_bytes": pack_bytes, + "reduction": reduction, + "bytes_per_token": BYTES_PER_TOKEN, + "approx_tokens_source": src_bytes // BYTES_PER_TOKEN, + "approx_tokens_pack": pack_bytes // BYTES_PER_TOKEN, + "faithfulness": _grounding(pack), + } diff --git a/client-plugin/server/src/index_graph/cache.py b/client-plugin/server/src/index_graph/cache.py new file mode 100644 index 0000000..9580238 --- /dev/null +++ b/client-plugin/server/src/index_graph/cache.py @@ -0,0 +1,118 @@ +"""Small filesystem cache for expensive workspace-wide index surfaces.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +from time import time +from typing import Any, Callable + +SCHEMA = "index.cache-entry/v1" + + +def _ttl_seconds() -> float: + raw = os.environ.get("INDEX_CACHE_TTL_SECONDS", "900") + try: + return max(0.0, float(raw)) + except ValueError: + return 900.0 + + +def _cache_dir() -> Path: + raw = os.environ.get("INDEX_CACHE_DIR") + if raw: + return Path(raw) + base = os.environ.get("LOCALAPPDATA") + if base: + return Path(base) / "index_graph" / "cache" + return Path.home() / ".cache" / "index_graph" + + +def _sha256_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def workspace_signature(root: Path) -> str: + """Cheap workspace signature for cache invalidation. + + This is intentionally cheaper than the full freshness fingerprint. It catches + config and top-level entry name changes, while the TTL bounds nested-change + staleness. Directory mtimes are excluded so volatile runtime trees such as + .scratch do not churn interactive cache keys before a cache lookup. + """ + root = root.resolve() + parts = [str(root)] + for cfg in (root / ".index.toml", root / ".repomap.toml"): + try: + stat = cfg.stat() + except OSError: + parts.append(f"{cfg.name}:missing") + else: + parts.append(f"{cfg.name}:{stat.st_mtime_ns}:{stat.st_size}") + try: + entries = sorted(root.iterdir(), key=lambda item: item.name.lower()) + except OSError as exc: + parts.append(f"root-error:{type(exc).__name__}:{exc}") + return _sha256_text("|".join(parts)) + for entry in entries: + try: + stat = entry.stat() + except OSError: + parts.append(f"{entry.name}:unstatable") + continue + kind = "d" if entry.is_dir() else "f" + if kind == "d": + parts.append(f"{entry.name}:{kind}") + else: + parts.append(f"{entry.name}:{kind}:{stat.st_mtime_ns}:{stat.st_size}") + return _sha256_text("|".join(parts)) + + +def _key(tool: str, root: Path, args: dict[str, Any]) -> str: + payload = { + "tool": tool, + "root": str(root.resolve()), + "args": args, + "workspace_signature": workspace_signature(root), + } + body = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str) + return _sha256_text(body) + + +def _path(key: str) -> Path: + return _cache_dir() / f"{key}.json" + + +def cached_text( + tool: str, + root: Path, + args: dict[str, Any], + build: Callable[[], str], + *, + enabled: bool = True, + cache_root: Path | None = None, +) -> str: + ttl = _ttl_seconds() + if not enabled or ttl <= 0: + return build() + key = _key(tool, root, args) + path = _path(key) if cache_root is None else cache_root / f"{key}.json" + now = time() + try: + data = json.loads(path.read_text(encoding="utf-8")) + if data.get("schema") == SCHEMA and now - float(data.get("created_at", 0.0)) <= ttl: + return str(data.get("text", "")) + except (OSError, json.JSONDecodeError, ValueError): + pass + text = build() + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"schema": SCHEMA, "created_at": now, "text": text}, separators=(",", ":")), + encoding="utf-8", + ) + except OSError: + pass + return text diff --git a/client-plugin/server/src/index_graph/certify/__init__.py b/client-plugin/server/src/index_graph/certify/__init__.py new file mode 100644 index 0000000..52d1327 --- /dev/null +++ b/client-plugin/server/src/index_graph/certify/__init__.py @@ -0,0 +1,6 @@ +"""Verdict certificates for check and drift.""" +from __future__ import annotations + +from .certificate import canonical_sha, build_certificate + +__all__ = ["canonical_sha", "build_certificate"] diff --git a/client-plugin/server/src/index_graph/certify/certificate.py b/client-plugin/server/src/index_graph/certify/certificate.py new file mode 100644 index 0000000..de3db4c --- /dev/null +++ b/client-plugin/server/src/index_graph/certify/certificate.py @@ -0,0 +1,42 @@ +"""The verdict certificate: re-checkable, three answers, never a fourth. + +A consumer believes a certificate by re-running its `recheck` command, +recomputing the hashes, and confirming the verdict. There is no TRUSTED. +""" +from __future__ import annotations + +import hashlib +import json + +_VERDICTS = frozenset({"MATCH", "DRIFT", "UNVERIFIABLE"}) + + +def canonical_sha(obj) -> str: + blob = json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(blob).hexdigest() + + +def build_certificate(kind: str, *, content: dict, criterion: dict | None, + verdict: str, findings: list[dict], recheck: str, + tool_version: str, coverage: dict | None = None, + freshness: dict | None = None) -> dict: + if verdict not in _VERDICTS: + raise ValueError(f"verdict must be one of {sorted(_VERDICTS)}, got {verdict!r}") + cert = { + "schema": "index.certificate/1", + "tool_version": tool_version, + "kind": kind, + "content_sha256": canonical_sha(content), + "criterion_sha256": canonical_sha(criterion) if criterion is not None else None, + "verdict": verdict, + "findings": findings, + "recheck": recheck, + } + if coverage is not None: + # soundness scope: what the verdict could and could not structurally verify + cert["coverage"] = coverage + if freshness is not None: + # the workspace content fingerprint at mint time, so a consumer can later + # ask `index freshness` whether the ground truth has moved since. + cert["freshness"] = freshness + return cert diff --git a/client-plugin/server/src/index_graph/classify.py b/client-plugin/server/src/index_graph/classify.py new file mode 100644 index 0000000..ea1a7aa --- /dev/null +++ b/client-plugin/server/src/index_graph/classify.py @@ -0,0 +1,29 @@ +"""Pure classification: ordered glob rules, then a remote-host fallback.""" + +from __future__ import annotations + +from urllib.parse import urlsplit + +from .config import PUBLIC_HOSTS, Config + + +def _remote_host(origin: str) -> str | None: + if not origin: + return None + if "://" not in origin and "@" in origin and ":" in origin: + # scp-like SSH form: git@github.com:owner/repo.git + return origin.split("@", 1)[1].split(":", 1)[0] or None + return urlsplit(origin).hostname + + +def classify(path: str, is_repo: bool, origin: str, config: Config) -> str: + for rule in config.rules: + if rule.regex.match(path): + return rule.class_ + if is_repo: + host = _remote_host(origin) + if host is None: + return "local" + return "public" if host in PUBLIC_HOSTS else "private" + name = path.rsplit("/", 1)[-1] + return "hidden" if name.startswith(".") else "entry" diff --git a/client-plugin/server/src/index_graph/cli.py b/client-plugin/server/src/index_graph/cli.py new file mode 100644 index 0000000..deb3ef6 --- /dev/null +++ b/client-plugin/server/src/index_graph/cli.py @@ -0,0 +1,190 @@ +"""Command-line entry point: map (default) + graph + context subcommands.""" + +from __future__ import annotations + +import json +import sys +from dataclasses import replace + +from . import __version__ +from .cli_handlers import ( + cmd_atlas, + cmd_workbench, + cmd_bench, + cmd_check, + cmd_context, + cmd_context_envelope, + cmd_lens, + cmd_drift, + cmd_freshness, + cmd_graph, + cmd_internals, + cmd_internals_symbols, + cmd_lsp, + cmd_mcp, + cmd_router, + cmd_serve, + cmd_snapshot, + cmd_symbols, + cmd_verify, + cmd_watch, + cmd_viz, +) +from .cli_parser import build_parser +from .router_job_surface import cmd_router_job +from .route import cmd_route +from .config import load_config +from .context.select import cmd_select +from .flagship import cmd_demo, cmd_doctor, cmd_status +from .freshness.invalidate_cli import cmd_invalidate +from .graph.walk import GraphSourceError +from .scan import build_map, write_map +from .wiki.cli import cmd_wiki + +_SUBCOMMANDS = { + "map", + "graph", + "context", + "context-envelope", + "lens", + "select", + "viz", + "atlas", + "workbench", + "wiki", + "internals", + "internals-symbols", + "symbols", + "check", + "snapshot", + "drift", + "router", + "router-job", + "route", + "verify", + "freshness", + "watch", + "invalidate", + "bench", + "serve", + "lsp", + "mcp", + "status", + "doctor", + "demo", +} + +# Dispatch table for named subcommands. `map` is intentionally absent: it is +# the implicit default reached when no known subcommand leads the invocation. +_DISPATCH = { + "status": cmd_status, + "doctor": cmd_doctor, + "demo": cmd_demo, + "atlas": cmd_atlas, + "workbench": cmd_workbench, + "wiki": cmd_wiki, + "graph": cmd_graph, + "context": cmd_context, + "context-envelope": cmd_context_envelope, + "lens": cmd_lens, + "select": cmd_select, + "viz": cmd_viz, + "internals": cmd_internals, + "internals-symbols": cmd_internals_symbols, + "symbols": cmd_symbols, + "check": cmd_check, + "snapshot": cmd_snapshot, + "drift": cmd_drift, + "router": cmd_router, + "router-job": cmd_router_job, + "route": cmd_route, + "verify": cmd_verify, + "freshness": cmd_freshness, + "watch": cmd_watch, + "invalidate": cmd_invalidate, + "bench": cmd_bench, + "serve": cmd_serve, + "lsp": cmd_lsp, + "mcp": cmd_mcp, +} + + +def _map_summary(data) -> str: + return ( + f"repos={data.repo_count} dirty_verified={data.dirty_count} " + f"metadata_status={data.metadata_status} " + f"metadata_unknown={data.metadata_unknown_count}" + ) + + +def _configure_stdio() -> None: + for stream_name in ("stdout", "stderr"): + stream = getattr(sys, stream_name, None) + if stream is None or not hasattr(stream, "reconfigure"): + continue + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except (OSError, ValueError): + pass + + +def _cmd_map(args) -> int: + root = args.root.resolve() + if not root.is_dir(): + raise SystemExit(f"root not found: {root}") + config = load_config(args.config, root) + if args.jobs is not None: + if args.jobs < 1: + raise SystemExit("--jobs must be a positive integer") + config = replace(config, jobs=args.jobs) + if args.json: + if args.dry_run: + raise SystemExit( + "map: --dry-run applies to the file-writing mode; " + "--json already writes nothing" + ) + print(json.dumps(build_map(root, config, __version__, resume_state=args.resume_state).to_json(), indent=2)) + return 0 + output = args.output.resolve() if args.output else root / "INDEX.json" + if args.dry_run: + print(f"index map: would write {output} (dry-run, nothing written)") + data = build_map(root, config, __version__, resume_state=args.resume_state) + print(_map_summary(data)) + return 0 + print(f"index map: writing {output}") + data = write_map(root, config, __version__, output, resume_state=args.resume_state) + print(f"wrote {output}") + print(_map_summary(data)) + return 0 + + +def _normalize_argv(argv: list[str] | None) -> list[str]: + raw = list(sys.argv[1:] if argv is None else argv) + # No leading subcommand: route top-level --version/--help to the root + # parser; otherwise treat the invocation as the implicit `map` command + # (preserves v0.2.0 behavior). + if not raw or raw[0] not in _SUBCOMMANDS: + if raw and raw[0] in ("--version", "-h", "--help"): + build_parser().parse_args(raw[:1]) # prints and exits + raw = ["map", *raw] + return raw + + +def main(argv: list[str] | None = None) -> int: + _configure_stdio() + args = build_parser().parse_args(_normalize_argv(argv)) + handler = _DISPATCH.get(args.cmd, _cmd_map) + try: + return handler(args) + except GraphSourceError as exc: + receipt = {"schema": "index.graph-error/v1", "status": "UNVERIFIABLE", + "error_type": type(exc).__name__, "message": str(exc)} + if getattr(args, "json", False): + print(json.dumps(receipt, indent=2, sort_keys=True)) + else: + print(f"UNVERIFIABLE: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/client-plugin/server/src/index_graph/cli_handlers/__init__.py b/client-plugin/server/src/index_graph/cli_handlers/__init__.py new file mode 100644 index 0000000..e7381ce --- /dev/null +++ b/client-plugin/server/src/index_graph/cli_handlers/__init__.py @@ -0,0 +1,41 @@ +"""Per-subcommand handler functions for the `index` CLI. + +Split out of the former monolithic ``cli.py`` so every module stays under +the 300-line ceiling and every handler under 50 lines. Behavior is identical +to the pre-split single-file implementation. +""" + +from __future__ import annotations + +from .certify import cmd_check, cmd_drift, cmd_snapshot +from .context import cmd_context, cmd_context_envelope, cmd_lens +from .graph import cmd_graph, cmd_internals, cmd_internals_symbols, cmd_viz +from .lsp import cmd_lsp +from .maps import cmd_atlas, cmd_router, cmd_workbench +from .serve import cmd_serve +from .symbols import cmd_symbols +from .verify import cmd_bench, cmd_freshness, cmd_mcp, cmd_verify, cmd_watch + +__all__ = [ + "cmd_atlas", + "cmd_workbench", + "cmd_bench", + "cmd_check", + "cmd_context", + "cmd_context_envelope", + "cmd_lens", + "cmd_drift", + "cmd_freshness", + "cmd_graph", + "cmd_internals", + "cmd_internals_symbols", + "cmd_lsp", + "cmd_mcp", + "cmd_router", + "cmd_serve", + "cmd_snapshot", + "cmd_symbols", + "cmd_verify", + "cmd_watch", + "cmd_viz", +] diff --git a/client-plugin/server/src/index_graph/cli_handlers/_common.py b/client-plugin/server/src/index_graph/cli_handlers/_common.py new file mode 100644 index 0000000..c3ceb7c --- /dev/null +++ b/client-plugin/server/src/index_graph/cli_handlers/_common.py @@ -0,0 +1,79 @@ +"""Shared helpers for the CLI subcommand handlers.""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +from ..config import load_config +from ..scan import ( + ScanBudget, + ScanBudgetExceeded, + discover_repos, + enforce_interactive_repo_limit, + repo_key_map, +) + + +def repo_paths(root: Path, *, skipped: list | None = None, budget_ms: int | None = None) -> dict[str, Path]: + # discover_repos requires a Config; use neutral defaults for graph/context. + # `skipped`, when given, collects directories the scan could not read, so a + # narrowed scan is a receiptable fact rather than a stderr-only warning. + config = load_config(None, root) + budget = ScanBudget(budget_ms) + repos = discover_repos( + root, + config, + skipped=skipped, + checkpoint=budget.checkpoint if budget.budget_ms > 0 else None, + ) + if budget.exhausted: + raise ScanBudgetExceeded(root=root, budget=budget, repo_count=len(repos), skipped=skipped) + keyed = repo_key_map( + root, + repos, + include_root_repo=config.include_root_repo, + ) + enforce_interactive_repo_limit(len(keyed), budget_ms=budget.budget_ms) + return keyed + + +def require_dir(root: Path) -> Path: + resolved = root.resolve() + if not resolved.is_dir(): + raise SystemExit(f"root not found: {resolved}") + return resolved + + +def rel_to_root(root: Path, p: Path) -> str: + r = p.resolve().relative_to(root).as_posix() + return "" if r == "." else r # a repo AT the root -> "" dir + + +def head_commit(root) -> str | None: + try: + out = subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], + capture_output=True, + text=True, + timeout=5, + ) + return out.stdout.strip() or None + except Exception: + return None + + +def emit_cert(cert: dict, as_json: bool) -> int: + if as_json: + print(json.dumps(cert, indent=2, sort_keys=True)) + else: + print(f"verdict={cert['verdict']} findings={len(cert['findings'])}") + for f in cert["findings"]: + loc = f" ({f['evidence']})" if f.get("evidence") else "" + print(f" [{f['rule']}] {f['detail']}{loc}") + cov = cert.get("coverage") + if cov is not None and not cov.get("complete", True): + n = len(cov.get("unverifiable_repos", {})) + print(f" coverage: incomplete, {n} repo(s) with unverifiable regions") + return 0 if cert["verdict"] == "MATCH" else 1 diff --git a/client-plugin/server/src/index_graph/cli_handlers/certify.py b/client-plugin/server/src/index_graph/cli_handlers/certify.py new file mode 100644 index 0000000..373f850 --- /dev/null +++ b/client-plugin/server/src/index_graph/cli_handlers/certify.py @@ -0,0 +1,228 @@ +"""Certificate handlers: check, snapshot, drift.""" + +from __future__ import annotations + +import json + +from .. import __version__ +from ..config import load_config +from ..context.pack import to_json +from ..graph.build import build_graph +from ._common import emit_cert, repo_paths, require_dir + + +def cmd_check(args) -> int: + from ..arch.check import check_graph + from ..certify import build_certificate + from ..freshness import workspace_fingerprint + + root = require_dir(args.root) + config = load_config(args.config, root) + crit = config.architecture + scan_skipped: list = [] + paths = repo_paths(root, skipped=scan_skipped) + graph = build_graph(paths) + pack = to_json(graph) + names = set(pack.get("roles", {}).keys()) + fresh_stamp = workspace_fingerprint(paths) if args.freshness else None + fresh_flag = " --freshness" if args.freshness else "" + + if not crit.declared: + cert = build_certificate( + "check", + content=pack, + criterion=None, + verdict="UNVERIFIABLE", + findings=[ + { + "rule": "criterion", + "detail": "no [architecture] criterion declared", + "edge": None, + "evidence": None, + } + ], + recheck=f"index check --root {args.root}{fresh_flag}", + tool_version=__version__, + freshness=fresh_stamp, + ) + return emit_cert(cert, args.json) + + findings = [ + {"rule": f.rule, "detail": f.detail, "edge": f.edge, "evidence": f.evidence} + for f in check_graph(pack, crit) + ] + # a scan narrowed by unreadable directories could not see the whole tree: + # a criterion-quality gap (UNVERIFIABLE), never a MATCH over a partial scan + if scan_skipped: + findings.append({ + "rule": "scan_incomplete", + "detail": f"repo discovery skipped {len(scan_skipped)} unreadable " + f"director(y/ies): {sorted(scan_skipped)[:3]}", + "edge": None, "evidence": None}) + internal_content = _check_internals(args, crit, paths, findings) + # an internal graph the analyzer could not fully build (parse errors / + # unreadable files) must not yield a MATCH: the map would certify structure + # it could not fully see. + internal_incomplete = internal_content is not None and any( + not repo.get("coverage", {}).get("complete", True) + for repo in internal_content.values()) + # a *_unmatched rule or a scan_incomplete gap is a criterion-quality gap + # (UNVERIFIABLE), not a breach; capture real_violations BEFORE layer + # findings are appended (order matters). + _gap_rules = {"scan_incomplete"} + real_violations = any( + not f["rule"].endswith("_unmatched") and f["rule"] not in _gap_rules + for f in findings) + unmatched = _check_layers(crit, names, findings) + verdict = _check_verdict(real_violations, unmatched, findings, internal_incomplete) + cert = _check_certificate( + args, crit, pack, internal_content, findings, verdict, fresh_stamp, fresh_flag + ) + return emit_cert(cert, args.json) + + +def _check_internals(args, crit, paths, findings) -> dict | None: + # optional intra-repo module checks: internal cycles against the ceiling + if not args.internals: + return None + from ..internals import build_internals + + internal_content: dict = {} + for name, p in sorted(paths.items()): + g = build_internals(p, name) + internal_content[name] = { + "cycles": [list(c) for c in g.cycles], + "coverage": { + "complete": g.coverage.complete, + "parse_errors": list(g.coverage.parse_errors), + "dynamic_imports": [ + {"file": fpath, "line": ln} + for fpath, ln in g.coverage.dynamic_imports + ], + }, + } + if crit.max_cycles is not None and len(g.cycles) > crit.max_cycles: + findings.append( + { + "rule": "max_cycles", + "detail": f"{name}: {len(g.cycles)} internal module cycle(s) " + f"exceed the ceiling of {crit.max_cycles}", + "edge": None, + "evidence": None, + } + ) + return internal_content + + +def _check_layers(crit, names, findings) -> list[str]: + # criterion-quality warnings: layers that name no repo + unmatched = [ + layer + for layer in crit.layers + if not any( + n == layer or n.startswith(layer + "/") or n.endswith("/" + layer) + for n in names + ) + ] + for layer in unmatched: + findings.append( + { + "rule": "layer", + "detail": f"layer '{layer}' matches no repo", + "edge": None, + "evidence": None, + } + ) + return unmatched + + +def _check_verdict(real_violations, unmatched, findings, internal_incomplete=False) -> str: + # a confirmed breach outranks an unverifiable criterion + if real_violations: + return "DRIFT" + # a *_unmatched criterion gap, an unmatched layer, an internal graph the + # analyzer could not fully build, or a scan narrowed by unreadable + # directories all read UNVERIFIABLE: a MATCH must not be issued over a + # graph or scan that is not fully derivable + if (unmatched or internal_incomplete + or any(f["rule"].endswith("_unmatched") + or f["rule"] == "scan_incomplete" for f in findings)): + return "UNVERIFIABLE" + return "MATCH" + + +def _check_certificate( + args, crit, pack, internal_content, findings, verdict, fresh_stamp, fresh_flag +): + from ..certify import build_certificate + + criterion_doc = { + "layers": list(crit.layers), + "forbid": [{"from": f.from_glob, "to": f.to_glob} for f in crit.forbid], + "max_cycles": crit.max_cycles, + "owns": [list(o) for o in crit.owns], + } + if crit.require: # keep empty-require criteria byte-identical (hash stability) + criterion_doc["require"] = [ + {"from": r.from_glob, "to": r.to_glob} for r in crit.require + ] + content = ( + pack + if internal_content is None + else {"pack": pack, "internals": internal_content} + ) + coverage_doc = None + if internal_content is not None: + incomplete = { + n: internal_content[n]["coverage"] + for n in internal_content + if not internal_content[n]["coverage"]["complete"] + } + coverage_doc = {"complete": not incomplete, "unverifiable_repos": incomplete} + recheck = ( + f"index check --root {args.root}" + + (" --internals" if args.internals else "") + + fresh_flag + ) + return build_certificate( + "check", + content=content, + criterion=criterion_doc, + verdict=verdict, + findings=findings, + recheck=recheck, + tool_version=__version__, + coverage=coverage_doc, + freshness=fresh_stamp, + ) + + +def cmd_snapshot(args) -> int: + from ..drift import dumps_canonical, snapshot_pack + + root = require_dir(args.root) + graph = build_graph(repo_paths(root)) + snap = snapshot_pack(to_json(graph)) + args.out.write_text(dumps_canonical(snap), encoding="utf-8") + print(f"wrote {args.out} repos={len(snap['repos'])} edges={len(snap['edges'])}") + return 0 + + +def cmd_drift(args) -> int: + from ..drift import diff_snapshots, load_snapshot + + old = load_snapshot(args.from_snap.read_text(encoding="utf-8")) + new = load_snapshot(args.to_snap.read_text(encoding="utf-8")) + try: + report = diff_snapshots(old, new) + except ValueError as exc: + raise SystemExit(f"drift: {exc}") + if args.json: + print(json.dumps(report.to_json(), indent=2)) + else: + print(f"verdict={report.verdict}") + for e in report.edges_added: + print(f" edge added: {e}") + for e in report.edges_removed: + print(f" edge removed: {e}") + return 0 if report.verdict == "MATCH" else 1 diff --git a/client-plugin/server/src/index_graph/cli_handlers/context.py b/client-plugin/server/src/index_graph/cli_handlers/context.py new file mode 100644 index 0000000..c139632 --- /dev/null +++ b/client-plugin/server/src/index_graph/cli_handlers/context.py @@ -0,0 +1,248 @@ +"""Context handlers: context pack and budgeted context envelope.""" + +from __future__ import annotations + +import json +import sys + +from ..context.focus import FocusRejection, focus_rejection, render_rejection +from ..context.pack import closure, focus_subgraph, preservation, render_text, to_json +from ..graph.build import build_graph +from ..scan import ScanBudgetExceeded, ScanWorkloadExceeded, default_interactive_budget_ms +from ._common import repo_paths + + +def _budget_ms(args) -> int: + value = getattr(args, "budget_ms", None) + if value is None: + value = default_interactive_budget_ms() + if value < 0: + raise SystemExit("--budget-ms must be non-negative") + return value + + +def _scan_budget_payload(command: str, exc: ScanBudgetExceeded | ScanWorkloadExceeded) -> dict: + payload = { + "schema": "index.scan-budget-exceeded/v1", + "command": command, + "status": "UNVERIFIABLE", + "message": str(exc), + "budget_ms": getattr(exc, "budget_ms", None), + "partial_repos": getattr(exc, "repo_count", None), + "next_actions": [ + "Increase --budget-ms for a larger bounded interactive scan.", + "Use --budget-ms 0 for an unbounded interactive run.", + "Use index map --resume-state PATH for complete repository inventory over large workspaces.", + ], + } + if hasattr(exc, "elapsed_ms"): + payload["elapsed_ms"] = exc.elapsed_ms + if hasattr(exc, "last_path"): + payload["last_path"] = exc.last_path + if hasattr(exc, "skipped"): + payload["skipped"] = exc.skipped + if hasattr(exc, "repo_limit"): + payload["repo_limit"] = exc.repo_limit + return payload + + +def _emit_scan_budget(args, command: str, exc: ScanBudgetExceeded | ScanWorkloadExceeded) -> int: + payload = _scan_budget_payload(command, exc) + if getattr(args, "json", False): + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(f"{command}: UNVERIFIABLE: {exc}") + print("next: increase --budget-ms, use --budget-ms 0, or run index map --resume-state PATH") + return 2 + + +def _write_bounded_json_stdout(payload: dict) -> None: + text = json.dumps(payload, indent=2, sort_keys=True) + "\n" + data = text.encode("utf-8") + buffer = getattr(sys.stdout, "buffer", None) + if buffer is not None: + buffer.write(data) + buffer.flush() + else: + sys.stdout.write(text) + sys.stdout.flush() + + +def cmd_context(args) -> int: + if args.hops is not None and args.hops < 0: + raise SystemExit("--hops must be >= 0") + try: + graph = build_graph(repo_paths(args.root.resolve(), budget_ms=_budget_ms(args))) + except (ScanBudgetExceeded, ScanWorkloadExceeded) as exc: + return _emit_scan_budget(args, "index context", exc) + names = {n.name for n in graph.repos} + if args.audit: + return _context_audit(graph) + preserved = None + if args.focus: + if args.focus not in names: + receipt = focus_rejection(args.focus, names) + print( + json.dumps(receipt, indent=2, sort_keys=True) + if args.json + else render_rejection(receipt) + ) + return 2 + keep = closure(list(graph.edges), args.focus, hops=args.hops) + preserved = preservation(list(graph.edges), keep, args.focus, args.hops) + graph = focus_subgraph(graph, keep) + title = f"focus={args.focus}" + ( + f" hops={args.hops}" if args.hops is not None else "" + ) + else: + title = "workstation context" + return _context_emit(args, graph, title, preserved) + + +def _context_audit(graph) -> int: + data = to_json(graph) + print(f"salience-faithfulness warnings: {len(data['salience_audit'])}") + for w in data["salience_audit"]: + print(f" [{w['kind']}] {w['node']} (in={w['in_degree']}): {w['note']}") + return 0 + + +def _context_emit(args, graph, title, preserved) -> int: + if args.json: + pack = to_json(graph) + if preserved is not None: + pack["preserved"] = preserved + print(json.dumps(pack, indent=2, sort_keys=True)) + else: + text = render_text(graph, title) + if preserved is not None: + b = preserved["boundary"] + text += ( + f"\n## Preserved\n- focus: {', '.join(preserved['focus'])}; " + f"hops: {preserved['hops']}; kept: {preserved['kept_nodes']} nodes\n" + f"- boundary dropped: {len(b['dropped_edges'])} edge(s) to " + f"{len(b['dropped_nodes'])} node(s)" + ) + print(text) + return 0 + + +def cmd_context_envelope(args) -> int: + if getattr(args, "verify", None) is not None: + return _verify_envelope(args) + if args.budget < 1: + raise SystemExit("--budget must be a positive integer") + if args.hops is not None and args.hops < 0: + raise SystemExit("--hops must be >= 0") + from ..context.envelope import build_context_envelope + + try: + graph = build_graph(repo_paths(args.root.resolve(), budget_ms=_budget_ms(args))) + except (ScanBudgetExceeded, ScanWorkloadExceeded) as exc: + return _emit_scan_budget(args, "index context-envelope", exc) + try: + env = build_context_envelope( + graph, + root=args.root.resolve(), + token_budget=args.budget, + focus=args.focus, + hops=args.hops, + bounded_output=args.bounded_output, + bounded_output_transport=( + "cli_json_stdout" if args.bounded_output and args.json else "canonical_json" + ), + ) + except FocusRejection as exc: + print( + json.dumps(exc.receipt, indent=2, sort_keys=True) + if args.json + else render_rejection(exc.receipt) + ) + return 2 + except ValueError as exc: + print(str(exc)) + return 2 + if args.json: + if args.bounded_output: + _write_bounded_json_stdout(env) + else: + print(json.dumps(env, indent=2, sort_keys=True)) + else: + print( + f"context-envelope verdict={env['verification_verdict']} " + f"tokens={env['budget']['approx_tokens']}/{env['budget']['token_budget']}" + ) + print(f"retained={len(env['retained'])} omitted={len(env['omitted'])}") + return 0 + + +def _verify_envelope(args) -> int: + from ..context.envelope import verify_envelope_freshness + + try: + envelope = json.loads(args.verify.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + print(f"could not read envelope {args.verify}: {exc}") + return 2 + try: + graph = build_graph(repo_paths(args.root.resolve(), budget_ms=_budget_ms(args))) + except (ScanBudgetExceeded, ScanWorkloadExceeded) as exc: + return _emit_scan_budget(args, "index context-envelope --verify", exc) + verdict = verify_envelope_freshness(envelope, graph) + if args.json: + print(json.dumps(verdict, indent=2, sort_keys=True)) + else: + print(f"envelope-freshness verdict={verdict['verdict']} " + f"root_ok={verdict['workspace_root_ok']}") + if verdict["drifted_repos"]: + print(f"drifted: {', '.join(verdict['drifted_repos'])}") + if verdict["missing_repos"]: + print(f"missing: {', '.join(verdict['missing_repos'])}") + return 0 if verdict["fresh"] else 1 + + +def cmd_lens(args) -> int: + if args.budget < 1: + raise SystemExit("--budget must be a positive integer") + if args.hops is not None and args.hops < 0: + raise SystemExit("--hops must be >= 0") + from ..context.lens import build_lens_pack + from ..viz.lens_html import render_lens_html + + graph = build_graph(repo_paths(args.root.resolve())) + try: + lens = build_lens_pack( + graph, + root=args.root.resolve(), + token_budget=args.budget, + focus=args.focus, + hops=args.hops, + ) + except FocusRejection as exc: + print( + json.dumps(exc.receipt, indent=2, sort_keys=True) + if args.json + else render_rejection(exc.receipt) + ) + return 2 + except ValueError as exc: + print(str(exc)) + return 2 + if args.json: + print(json.dumps(lens, indent=2, sort_keys=True)) + return 0 + out = getattr(args, "out", None) + if out: + from pathlib import Path + + Path(out).write_text(render_lens_html(lens), encoding="utf-8") + env = lens["envelope"] + print( + f"context lens -> {out} " + f"(verdict={env['verification_verdict']}, " + f"{len(env['retained'])} retained / {len(env['omitted'])} omitted " + f"at budget {env['budget']['token_budget']})" + ) + else: + print(render_lens_html(lens)) + return 0 diff --git a/client-plugin/server/src/index_graph/cli_handlers/graph.py b/client-plugin/server/src/index_graph/cli_handlers/graph.py new file mode 100644 index 0000000..b311325 --- /dev/null +++ b/client-plugin/server/src/index_graph/cli_handlers/graph.py @@ -0,0 +1,237 @@ +"""Graph-shaped handlers: graph, internals, viz.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .. import __version__ +from ..context.focus import focus_rejection, render_rejection +from ..context.pack import closure, focus_subgraph, render_text, to_json +from ..graph.build import build_graph +from ..graph.progress import stderr_progress +from ..scan import ScanBudgetExceeded, ScanWorkloadExceeded, default_interactive_budget_ms +from ._common import head_commit, repo_paths, require_dir + + +def _budget_ms(args) -> int: + value = getattr(args, "budget_ms", None) + if value is None: + value = default_interactive_budget_ms() + if value < 0: + raise SystemExit("--budget-ms must be non-negative") + return value + + +def _emit_scan_budget(args, command: str, exc: ScanBudgetExceeded) -> int: + if getattr(args, "json", False): + payload = { + "schema": "index.scan-budget-exceeded/v1", + "command": command, + "status": "UNVERIFIABLE", + "message": str(exc), + "budget_ms": getattr(exc, "budget_ms", None), + "partial_repos": getattr(exc, "repo_count", None), + } + if hasattr(exc, "elapsed_ms"): + payload["elapsed_ms"] = exc.elapsed_ms + if hasattr(exc, "last_path"): + payload["last_path"] = exc.last_path + if hasattr(exc, "skipped"): + payload["skipped"] = exc.skipped + if hasattr(exc, "repo_limit"): + payload["repo_limit"] = exc.repo_limit + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(f"{command}: UNVERIFIABLE: {exc}") + return 2 + + +def cmd_graph(args) -> int: + try: + graph = build_graph(repo_paths(args.root.resolve(), budget_ms=_budget_ms(args)), + executor="process", on_progress=stderr_progress()) + except (ScanBudgetExceeded, ScanWorkloadExceeded) as exc: + return _emit_scan_budget(args, "index graph", exc) + if getattr(args, "cycles", False): + from ..graph.cycles import find_cycles + + cycles = find_cycles(graph.edges) + if args.json: + print(json.dumps({"cycles": [list(c) for c in cycles]}, indent=2)) + elif not cycles: + print("no cycles, a clean DAG") + else: + print(f"{len(cycles)} cycle(s):") + for c in cycles: + print(f" - {' -> '.join(c)} -> {c[0]}") + return 0 + if args.json: + print(json.dumps(to_json(graph), indent=2)) + else: + print(render_text(graph, "dependency graph")) + return 0 + + +def cmd_internals(args) -> int: + from ..internals import build_internals + + root = require_dir(args.root) + g = build_internals(root) + if getattr(args, "cycles", False): + return _internals_cycles(args, g) + payload = _internals_payload(g) + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + cov = ( + "complete" + if g.coverage.complete + else f"{len(g.coverage.parse_errors)} unparsed, " + f"{len(g.coverage.dynamic_imports)} dynamic" + ) + print( + f"modules={len(g.modules)} edges={len(g.edges)} " + f"cycles={len(g.cycles)} coverage={cov}" + ) + return 0 + + +def _internals_cycles(args, g) -> int: + if args.json: + print(json.dumps({"cycles": [list(c) for c in g.cycles]}, indent=2)) + elif not g.cycles: + print("no internal cycles - clean DAG") + else: + print(f"{len(g.cycles)} internal cycle(s):") + for c in g.cycles: + print(f" - {' -> '.join(c)}") + return 0 + + +def _internals_payload(g) -> dict: + return { + "repo": g.repo, + "modules": [ + {"id": m.id, "path": m.path, "language": m.language} for m in g.modules + ], + "edges": [ + { + "from": e.from_id, + "to": e.to_id, + "file": e.evidence_file, + "line": e.evidence_line, + "raw": e.raw, + } + for e in g.edges + ], + "cycles": [list(c) for c in g.cycles], + "fan_in": g.fan_in, + "fan_out": g.fan_out, + "coverage": { + "complete": g.coverage.complete, + "modules": g.coverage.modules, + "internal_edges": g.coverage.internal_edges, + "parse_errors": list(g.coverage.parse_errors), + "dynamic_imports": [ + {"file": fpath, "line": ln} for fpath, ln in g.coverage.dynamic_imports + ], + }, + } + + +def cmd_internals_symbols(args) -> int: + from ..symbols import build_symbol_graph, symbol_graph_to_payload + + root = require_dir(args.root) + g = build_symbol_graph(root) + payload = symbol_graph_to_payload(g) + if getattr(args, "coverage", False): + cov = payload["coverage"] + if args.json: + print(json.dumps(cov, indent=2, sort_keys=True)) + else: + print( + f"symbols={cov['symbols']} resolved={cov['resolved_calls']} " + f"unresolved={cov['unresolved_calls']} " + f"parse_errors={len(cov['parse_errors'])} " + f"dynamic={len(cov['dynamic_calls'])}" + ) + return 0 + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + cov = payload["coverage"] + print( + f"symbols={len(payload['symbols'])} calls={len(payload['calls'])} " + f"resolved={cov['resolved_calls']} unresolved={cov['unresolved_calls']}" + ) + return 0 + + +def cmd_viz(args) -> int: + from .. import viz + + graph = build_graph(repo_paths(args.root.resolve())) + names = {n.name for n in graph.repos} + if args.focus: + if args.focus not in names: + print(render_rejection(focus_rejection(args.focus, names))) + return 2 + graph = focus_subgraph(graph, closure(list(graph.edges), args.focus)) + pack = to_json(graph) + include_external = not args.no_external + + def _svg() -> str: + return viz.render_svg(viz.build_layout(pack, include_external=include_external)) + + def _html() -> str: + return viz.render_html( + pack, + svg=_svg(), + charts=viz.render_charts(pack, include_external=include_external), + ) + + if args.format == "all": + return _viz_all(args, viz, pack, include_external, _svg, _html) + text = { + "svg": _svg, + "mermaid": lambda: viz.render_mermaid(pack, include_external=include_external), + "html": _html, + }[args.format]() + if args.out: + Path(args.out).write_text(text, encoding="utf-8") + else: + print(text) + return 0 + + +def _viz_all(args, viz, pack, include_external, _svg, _html) -> int: + out_dir = Path(args.out_dir or ".") + out_dir.mkdir(parents=True, exist_ok=True) + files = { + "graph.mmd": viz.render_mermaid(pack, include_external=include_external).encode( + "utf-8" + ), + "graph.svg": _svg().encode("utf-8"), + "graph.html": _html().encode("utf-8"), + "context.json": json.dumps(pack, indent=2).encode("utf-8"), + } + for name, data in files.items(): + (out_dir / name).write_bytes(data) + artifacts = { + "mermaid": ("graph.mmd", files["graph.mmd"]), + "svg": ("graph.svg", files["graph.svg"]), + "html": ("graph.html", files["graph.html"]), + "context": ("context.json", files["context.json"]), + } + meta = { + "version": __version__, + "commit": head_commit(args.root.resolve()), + "root": str(args.root), + } + manifest = viz.render_manifest(pack, artifacts=artifacts, meta=meta) + (out_dir / "context-manifest.json").write_text( + json.dumps(manifest, indent=2), encoding="utf-8" + ) + return 0 diff --git a/client-plugin/server/src/index_graph/cli_handlers/lsp.py b/client-plugin/server/src/index_graph/cli_handlers/lsp.py new file mode 100644 index 0000000..e8a8c38 --- /dev/null +++ b/client-plugin/server/src/index_graph/cli_handlers/lsp.py @@ -0,0 +1,16 @@ +"""Handler for `index lsp`: start the stdio LSP server on a workspace root. + +A thin adapter over ``index_graph.lsp.LSPServer``. It binds no socket and holds +no model; it reads Content-Length-framed JSON-RPC from stdin and writes answers +to stdout, so an IDE (VSCode/Neovim/JetBrains) can consume index's verified +symbol graph natively. Tests may inject in-memory binary streams via the private +``_stdin``/``_stdout`` attributes on the args object. +""" +from __future__ import annotations + + +def cmd_lsp(args) -> int: + from ..lsp import LSPServer + + server = LSPServer(root=args.root, trace=getattr(args, "trace", "off")) + return server.serve(getattr(args, "_stdin", None), getattr(args, "_stdout", None)) diff --git a/client-plugin/server/src/index_graph/cli_handlers/maps.py b/client-plugin/server/src/index_graph/cli_handlers/maps.py new file mode 100644 index 0000000..469b678 --- /dev/null +++ b/client-plugin/server/src/index_graph/cli_handlers/maps.py @@ -0,0 +1,131 @@ +"""Knowledge-map handlers: atlas and router.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from ..cache import cached_text +from ..scan import ScanBudgetExceeded, ScanWorkloadExceeded, default_interactive_budget_ms +from ..graph.build import build_graph +from ..graph.progress import stderr_progress +from ._common import rel_to_root, repo_paths, require_dir + + +def cmd_atlas(args) -> int: + from ..knowledge.atlas import build_atlas_pack + from ..knowledge.docs import discover_docs + + root = require_dir(args.root) + paths = repo_paths(root) + repo_dirs = {name: rel_to_root(root, p) for name, p in paths.items()} + graph = build_graph(paths) + docs = discover_docs(root) + pack = build_atlas_pack(graph, docs, repo_dirs) + if args.format == "html": + return _atlas_html(args, pack, docs) + if args.json: + print(json.dumps(pack, indent=2)) + else: + print( + f"repos={len(pack['repos'])} docs={len(pack['docs'])} " + f"knowledge_edges={len(pack['knowledge_edges'])}" + ) + return 0 + + +def cmd_workbench(args) -> int: + from ..knowledge.docs import discover_docs + from ..viz.workbench_html import render_workbench_html + from ..workbench import build_workbench_pack + + if args.budget < 1: + raise SystemExit("--budget must be a positive integer") + root = require_dir(args.root) + paths = repo_paths(root) + repo_dirs = {name: rel_to_root(root, p) for name, p in paths.items()} + graph = build_graph(paths) + docs = discover_docs(root) + wb = build_workbench_pack( + graph, docs, repo_dirs, + root=root, token_budget=args.budget, spine_dir=args.spine_dir, + max_doc_bodies=args.max_doc_bodies) + if args.json: + print(json.dumps({k: v for k, v in wb.items() if k != "svg"}, + indent=2, sort_keys=True)) + return 0 + page = render_workbench_html(wb) + if args.out: + Path(args.out).write_text(page, encoding="utf-8") + s = wb["summary"] + print(f"workbench -> {args.out} ({s['repos']} repos, {s['docs']} docs, " + f"{len(wb['spine']['tools'])} spine envelopes, " + f"receipt {wb['receipt_sha256'][:16]}…)") + else: + print(page) + return 0 + + +def _atlas_html(args, pack, docs) -> int: + from .. import viz + + include_external = not args.no_external + svg = viz.render_atlas_svg( + viz.build_atlas_layout(pack, include_external=include_external) + ) + html = viz.render_atlas_html(pack, docs, svg=svg, include_external=include_external) + if args.out: + Path(args.out).write_text(html, encoding="utf-8") + print(f"wrote {args.out}") + else: + print(html) + return 0 + + +def cmd_router(args) -> int: + from ..knowledge.atlas import build_router_pack + from ..knowledge.docs import discover_router_docs + from ..router import render_router + from ..router_inventory import build_router_inventory + + root = require_dir(args.root) + max_docs = max(0, int(getattr(args, "max_docs", 500))) + budget_ms = getattr(args, "budget_ms", None) + if budget_ms is None: + budget_ms = default_interactive_budget_ms() + if budget_ms < 0: + raise SystemExit("--budget-ms must be non-negative") + + def _build() -> str: + inventory = build_router_inventory(root, budget_ms=budget_ms) + pack = build_router_pack( + build_graph( + inventory.repo_paths, + executor="process", + use_cache=not getattr(args, "no_cache", False), + on_progress=stderr_progress(), + file_lists=inventory.repo_file_lists, + ), + discover_router_docs(root, paths=inventory.router_doc_paths), + inventory.repo_dirs, + ) + return render_router(pack, max_docs=max_docs) + + try: + text = cached_text( + "router", + root, + {"max_docs": max_docs, "budget_ms": budget_ms}, + _build, + enabled=not getattr(args, "no_cache", False), + ) + except (ScanBudgetExceeded, ScanWorkloadExceeded) as exc: + print(f"index router: UNVERIFIABLE: {exc}") + print("next: increase --budget-ms, use --budget-ms 0, or run index map --resume-state PATH") + return 2 + if args.out: + Path(args.out).write_text(text, encoding="utf-8") + print(f"wrote {args.out}") + else: + print(text) + return 0 diff --git a/client-plugin/server/src/index_graph/cli_handlers/serve.py b/client-plugin/server/src/index_graph/cli_handlers/serve.py new file mode 100644 index 0000000..f5bf36a --- /dev/null +++ b/client-plugin/server/src/index_graph/cli_handlers/serve.py @@ -0,0 +1,14 @@ +"""Handler for `index serve`: run the on-demand verified-wiki HTTP server. + +Delegates to the wiki serve module so the CLI handler stays a thin adapter and +the server logic lives with the rest of the wiki surface. Binds loopback by +default; a --host/--port override is allowed but defaults keep it local. +""" + +from __future__ import annotations + + +def cmd_serve(args) -> int: + from ..wiki.serve import serve_forever + + return serve_forever(host=args.host, port=args.port) diff --git a/client-plugin/server/src/index_graph/cli_handlers/symbols.py b/client-plugin/server/src/index_graph/cli_handlers/symbols.py new file mode 100644 index 0000000..7426113 --- /dev/null +++ b/client-plugin/server/src/index_graph/cli_handlers/symbols.py @@ -0,0 +1,106 @@ +"""Handler for `index symbols `: symbol-granular navigation. + +Go-to-definition, find-references, and find-implementations over the wave-1 +symbol graph, each hop carrying file:line evidence. The query is a symbol id +(``module::name`` or ``Class::method``) or a bare name. With no mode flag, all +three sections are reported; ``--def`` / ``--refs`` / ``--impls`` select one. + +Every row defers to the graph's own resolution, so nothing is guessed: an +unresolved reference is listed separately and never as a caller, and an +external base class yields no implementation edge. +""" +from __future__ import annotations + +import json + +from ..symbols import (build_symbol_navigator, find_definitions, + find_implementations, find_references) +from ._common import require_dir + + +def cmd_symbols(args) -> int: + root = require_dir(args.root) + query = args.query.strip() + graph, edges = build_symbol_navigator(root) + want_def, want_refs, want_impls = _modes(args) + + result: dict = {"repo": graph.repo, "query": query} + if want_def: + result["definitions"] = find_definitions(graph, query) + if want_refs: + result["references"] = find_references(graph, query) + if want_impls: + result["implementations"] = find_implementations(graph, edges, query) + + if args.json: + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 + _render_text(result, want_def, want_refs, want_impls) + return _exit_code(result, want_def, want_refs, want_impls) + + +def _modes(args) -> tuple[bool, bool, bool]: + """Selected sections; no flag means all three.""" + d, r, i = args.definition, args.references, args.implementations + if not (d or r or i): + return True, True, True + return d, r, i + + +def _exit_code(result: dict, want_def, want_refs, want_impls) -> int: + """0 when the query matched something; 2 when every requested section was empty. + + A non-zero code lets scripts distinguish "no such symbol / no evidence" from + a successful lookup, without ever fabricating a match. + """ + found = False + if want_def: + found = found or bool(result["definitions"]) + if want_refs: + refs = result["references"] + found = found or bool(refs["references"]) or bool(refs["unresolved"]) + if want_impls: + impls = result["implementations"] + found = found or bool(impls["subclasses"]) or bool(impls["overrides"]) + return 0 if found else 2 + + +def _render_text(result: dict, want_def, want_refs, want_impls) -> None: + print(f"symbol query: {result['query']} (repo {result['repo']})") + if want_def: + _render_definitions(result["definitions"]) + if want_refs: + _render_references(result["references"]) + if want_impls: + _render_implementations(result["implementations"]) + + +def _render_definitions(defs: list) -> None: + print(f"definitions ({len(defs)}):") + if not defs: + print(" (none: no matching definition in this repo)") + for d in defs: + vis = "public" if d["is_public"] else "internal" + print(f" {d['id']} [{d['kind']}, {vis}] {d['file']}:{d['line']}") + + +def _render_references(refs: dict) -> None: + resolved = refs["references"] + unresolved = refs["unresolved"] + print(f"references ({len(resolved)} resolved, {len(unresolved)} unresolved):") + for r in resolved: + print(f" {r['from_symbol']} {r['file']}:{r['line']} " + f"[{r['resolution']}, {r['confidence']}]") + for u in unresolved: + print(f" ? {u['from_symbol']} {u['file']}:{u['line']} " + f"(unresolved same-name reference, never a caller)") + + +def _render_implementations(impls: dict) -> None: + subs = impls["subclasses"] + overs = impls["overrides"] + print(f"implementations ({len(subs)} subclasses, {len(overs)} overrides):") + for s in subs: + print(f" subclass {s['child']} {s['file']}:{s['line']} [{s['resolution']}]") + for o in overs: + print(f" override {o['child']} {o['file']}:{o['line']} [{o['resolution']}]") diff --git a/client-plugin/server/src/index_graph/cli_handlers/verify.py b/client-plugin/server/src/index_graph/cli_handlers/verify.py new file mode 100644 index 0000000..1f7d459 --- /dev/null +++ b/client-plugin/server/src/index_graph/cli_handlers/verify.py @@ -0,0 +1,235 @@ +"""Grounding handlers: verify, freshness, bench, mcp.""" + +from __future__ import annotations + +import json + +from .. import __version__ +from ..cache import cached_text +from ..context.pack import to_json +from ..graph.build import build_graph +from ._common import repo_paths, require_dir + + +def cmd_watch(args) -> int: + """Auto-resync: hold the prior fingerprint, recompute on each tick, emit a + live FRESH/STALE receipt on change, and (optionally) regenerate an artifact.""" + import time as _time + + from ..freshness.watch import watch_iter + from ._common import rel_to_root + + if args.interval <= 0: + raise SystemExit("watch: --interval must be positive") + root = require_dir(args.root) + + def _paths(): + return repo_paths(root) + + regen = None + if args.regen: + regen = _make_regen(args, root, rel_to_root) + + max_ticks = args.max_ticks if args.max_ticks and args.max_ticks > 0 else None + changed = 0 + try: + for report in watch_iter(_paths(), interval=args.interval, + max_ticks=max_ticks): + if args.json: + print(json.dumps(report), flush=True) + elif report["tick"] == 0: + print(f"watching {root} — baseline {report['curr_root'][:12]}… " + f"(every {args.interval}s; Ctrl-C to stop)", flush=True) + else: + deltas = (report.get("repos_changed", []) + + ["+" + a for a in report.get("repos_added", [])] + + ["-" + r for r in report.get("repos_removed", [])]) + print(f"[tick {report['tick']}] {report['verdict']}: " + f"{', '.join(deltas) or report.get('error', '—')}", flush=True) + if regen and report["tick"] > 0 and report["verdict"] == "STALE": + out = regen() + if not args.json: + print(f" regenerated {out}", flush=True) + if report["tick"] > 0 and report["verdict"] == "STALE": + changed += 1 + except KeyboardInterrupt: + if not args.json: + print(f"\nstopped — {changed} resync(s) detected", flush=True) + return 0 + + +def _make_regen(args, root, rel_to_root): + """Return a zero-arg callable that regenerates the requested artifact and + returns its path. Reuses the same builders the one-shot subcommands use.""" + from pathlib import Path + + from ..knowledge.docs import discover_docs + + out = Path(args.out or f"index-{args.regen}.html") + + def _run(): + paths = repo_paths(root) + graph = build_graph(paths) + if args.regen == "workbench": + from ..viz.workbench_html import render_workbench_html + from ..workbench import build_workbench_pack + repo_dirs = {n: rel_to_root(root, p) for n, p in paths.items()} + wb = build_workbench_pack(graph, discover_docs(root), repo_dirs, root=root) + out.write_text(render_workbench_html(wb), encoding="utf-8") + elif args.regen == "atlas": + from .. import viz + from ..knowledge.atlas import build_atlas_pack + docs = discover_docs(root) + repo_dirs = {n: rel_to_root(root, p) for n, p in paths.items()} + pack = build_atlas_pack(graph, docs, repo_dirs) + svg = viz.render_atlas_svg(viz.build_atlas_layout(pack)) + out.write_text(viz.render_atlas_html(pack, docs, svg=svg), encoding="utf-8") + return str(out) + + return _run + + +def cmd_verify(args) -> int: + from ..verify import build_verification + + root = require_dir(args.root) + if (args.depends is None) == (args.exists is None): + raise SystemExit( + "verify: pass exactly one of --depends 'A -> B' or --exists NAME" + ) + if args.exists is not None and not args.exists.strip(): + raise SystemExit("verify: --exists NAME must be non-empty") + if args.depends: + if "->" not in args.depends: + raise SystemExit("verify: --depends must be 'A -> B'") + frm, to = (s.strip() for s in args.depends.split("->", 1)) + claim = {"kind": "depends", "from": frm, "to": to} + recheck = f'index verify --root "{args.root}" --depends "{args.depends}"' + else: + claim = {"kind": "exists", "name": args.exists.strip()} + recheck = f'index verify --root "{args.root}" --exists "{args.exists}"' + pack = to_json(build_graph(repo_paths(root))) + rec = build_verification(pack, claim, tool_version=__version__, recheck=recheck) + if args.json: + print(json.dumps(rec, indent=2, sort_keys=True)) + else: + loc = f" ({rec['evidence']})" if rec["evidence"] else "" + print(f"verdict={rec['verdict']}: {rec['detail']}{loc}") + return {"MATCH": 0, "REFUTED": 1, "UNVERIFIABLE": 2}[rec["verdict"]] + + +def cmd_freshness(args) -> int: + from ..freshness import REPORT_SCHEMA, compare_freshness, workspace_fingerprint + + root = require_dir(args.root) + try: + cert = json.loads(args.cert.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"freshness: cannot read certificate {args.cert}: {exc}") + stamp = cert.get("freshness") if isinstance(cert, dict) else None + if not stamp: + report = { + "schema": REPORT_SCHEMA, + "verdict": "UNVERIFIABLE", + "detail": "certificate carries no freshness stamp " + "(mint it with index check --freshness)", + } + else: + try: + report = compare_freshness(stamp, workspace_fingerprint(repo_paths(root))) + except ValueError as exc: + report = { + "schema": REPORT_SCHEMA, + "verdict": "UNVERIFIABLE", + "detail": str(exc), + } + report["recheck"] = f'index freshness --cert "{args.cert}" --root "{args.root}"' + return _freshness_emit(args, report) + + +def _freshness_emit(args, report) -> int: + if args.json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + line = f"verdict={report['verdict']}" + if report.get("detail"): + line += f": {report['detail']}" + print(line) + for n in report.get("repos_changed", []): + print(f" changed: {n}") + for n in report.get("repos_added", []): + print(f" added: {n}") + for n in report.get("repos_removed", []): + print(f" removed: {n}") + return {"FRESH": 0, "STALE": 1, "UNVERIFIABLE": 2}[report["verdict"]] + + +def cmd_bench(args) -> int: + root = require_dir(args.root) + text = cached_text( + "bench", + root, + {"json": bool(args.json)}, + lambda: _bench_output(args, root), + enabled=not getattr(args, "no_cache", False), + ) + print(text) + return 0 + + +def _bench_output(args, root) -> str: + from ..bench import bench_workspace + + report = bench_workspace( + repo_paths(root), + use_graph_cache=not getattr(args, "no_cache", False), + ) + report["recheck"] = f"index bench --root {args.root}" + if args.json: + return json.dumps(report, indent=2, sort_keys=True) + return _bench_human_text(report) + + +def _bench_human_text(report: dict) -> str: + st, pk = report["source_bytes"], report["pack_bytes"] + red = report["reduction"] + red_txt = f" {red}x smaller" if red else "" + lines = [ + "token economy: index's structural pack vs the source it reads", + ( + f" source read {st:>11,} bytes " + f"(~{report['approx_tokens_source']:,} tokens) " + f"{report['source_files']} files in {report['repos']} repos" + ), + ( + f" index pack {pk:>11,} bytes " + f"(~{report['approx_tokens_pack']:,} tokens){red_txt}" + ), + ] + f = report["faithfulness"] + if f["edge_grounding"] is None: + lines.append( + f" faithfulness n/a: {f['internal_edges']} internal edges " + "(nothing to ground)" + ) + else: + lines.append( + f" faithfulness {f['edge_grounding'] * 100:.0f}% of " + f"{f['internal_edges']} kept edges grounded in file:line source " + "(the reduction fabricates nothing)" + ) + lines.append( + f" note: ~{report['bytes_per_token']} bytes/token is an approximation; " + "the reduction ratio does not depend on it." + ) + lines.append( + " the pack answers structural questions (depends-on, roles, " + "cycles); reading the code is still needed for behavior." + ) + return "\n".join(lines) + + +def cmd_mcp(args) -> int: + from ..mcp import serve + + return serve() diff --git a/client-plugin/server/src/index_graph/cli_parser.py b/client-plugin/server/src/index_graph/cli_parser.py new file mode 100644 index 0000000..b133ddb --- /dev/null +++ b/client-plugin/server/src/index_graph/cli_parser.py @@ -0,0 +1,474 @@ +"""Argument-parser construction for the `index` CLI. + +``build_parser`` orchestrates per-subcommand builder helpers so no single +function exceeds the 50-line ceiling. The set of subcommands, flags, and +help text is identical to the pre-split single-file parser. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from . import __version__ +from .wiki.cli import add_wiki_parser + + +def _add_budget_ms_arg(p: argparse.ArgumentParser) -> None: + p.add_argument( + "--budget-ms", + type=int, + default=None, + help="repository-discovery time budget in milliseconds; 0 means unbounded", + ) + + +def _add_map_args(p: argparse.ArgumentParser) -> None: + p.add_argument("--root", type=Path, default=Path.cwd()) + p.add_argument("--output", type=Path, default=None) + p.add_argument("--json", action="store_true") + p.add_argument( + "--dry-run", + action="store_true", + help="report the write path and repo counts without writing anything", + ) + p.add_argument("--config", type=Path, default=None) + p.add_argument("--jobs", type=int, default=None) + p.add_argument( + "--resume-state", + type=Path, + default=None, + help="JSONL checkpoint file that lets complete map builds resume completed repo rows", + ) + + +def _add_telos_parser(sub, name: str, help_text: str) -> None: + p = sub.add_parser(name, help=help_text) + p.add_argument( + "--json", action="store_true", help="emit a Project Telos action envelope" + ) + + +def _add_graph_parser(sub) -> None: + g = sub.add_parser("graph", help="Derive the repo-level dependency graph.") + g.add_argument("--root", type=Path, default=Path.cwd()) + g.add_argument("--json", action="store_true") + _add_budget_ms_arg(g) + g.add_argument( + "--cycles", + action="store_true", + help="Report dependency cycles instead of the full graph.", + ) + + +def _add_context_parser(sub) -> None: + c = sub.add_parser("context", help="Render the synthesis context pack.") + c.add_argument("--root", type=Path, default=Path.cwd()) + c.add_argument("--json", action="store_true") + c.add_argument("--focus", default=None) + c.add_argument("--hops", type=int, default=None) + c.add_argument("--audit", action="store_true") + _add_budget_ms_arg(c) + + +def _add_context_envelope_parser(sub) -> None: + ce = sub.add_parser( + "context-envelope", + help="Emit a budgeted, receipt-backed context envelope.", + ) + ce.add_argument("--root", type=Path, default=Path.cwd()) + ce.add_argument( + "--budget", + type=int, + default=1200, + help="approximate token budget for retained context entries", + ) + ce.add_argument("--focus", default=None) + ce.add_argument("--hops", type=int, default=None) + ce.add_argument( + "--bounded-output", + action="store_true", + help="opt in to bounding the serialized JSON response, omitting source refs with expansion handles when needed", + ) + ce.add_argument("--json", action="store_true") + _add_budget_ms_arg(ce) + ce.add_argument( + "--verify", + type=Path, + default=None, + metavar="ENVELOPE_JSON", + help="re-derive a saved envelope's freshness against the current workspace " + "(exit 0 if still fresh, 1 if it drifted); other build flags are ignored", + ) + + +def _add_route_parser(sub) -> None: + route = sub.add_parser( + "route", + help="Build a bounded context envelope from explicit repository paths.", + ) + route.add_argument("--root", type=Path, default=Path.cwd()) + route.add_argument( + "--path", + dest="paths", + action="append", + required=True, + help="repository path under --root; repeat for multiple repositories", + ) + route.add_argument("--budget", type=int, default=1200, help="context token budget") + route.add_argument("--hops", type=int, default=None) + route.add_argument("--json", action="store_true") + + +def _add_lens_parser(sub) -> None: + ln = sub.add_parser( + "lens", + help="Render the Context Lens: a live, self-contained page showing " + "what a token budget retains and drops, with failure codes.", + ) + ln.add_argument("--root", type=Path, default=Path.cwd()) + ln.add_argument( + "--budget", + type=int, + default=1200, + help="approximate token budget the lens opens at (slider varies it live)", + ) + ln.add_argument("--focus", default=None) + ln.add_argument("--hops", type=int, default=None) + ln.add_argument("--out", default=None, help="write the HTML here instead of stdout") + ln.add_argument("--json", action="store_true", help="emit the lens pack JSON instead of HTML") + + +def _add_workbench_parser(sub) -> None: + wbp = sub.add_parser( + "workbench", + help="Render the unified workbench: map, docs, context lens, health, " + "and the flagship spine in one self-contained page.", + ) + wbp.add_argument("--root", type=Path, default=Path.cwd()) + wbp.add_argument("--budget", type=int, default=6000, + help="token budget the context lens opens at") + wbp.add_argument("--max-doc-bodies", type=int, default=200, + help="rendered doc bodies embedded in the page (page-weight budget; the doc list and search always cover all docs)") + wbp.add_argument("--spine-dir", default=None, + help="directory of captured flagship-action envelopes (*.json)") + wbp.add_argument("--out", default=None, help="write the HTML here instead of stdout") + wbp.add_argument("--json", action="store_true", + help="emit the workbench pack JSON (minus svg) instead of HTML") + + +def _add_watch_parser(sub) -> None: + w = sub.add_parser( + "watch", + help="Auto-resync on file change: hold the prior fingerprint, emit a " + "live FRESH/STALE receipt per change, optionally regenerate an artifact.", + ) + w.add_argument("--root", type=Path, default=Path.cwd()) + w.add_argument("--interval", type=float, default=2.0, + help="poll seconds (latency floor; the fingerprint is authoritative)") + w.add_argument("--max-ticks", type=int, default=0, + help="stop after N ticks (0 = run until Ctrl-C)") + w.add_argument("--regen", choices=["workbench", "atlas"], default=None, + help="regenerate this artifact on every detected change") + w.add_argument("--out", default=None, help="artifact output path for --regen") + w.add_argument("--json", action="store_true", + help="emit one freshness-sync receipt (JSON) per line") + + +def _add_select_parser(sub) -> None: + se = sub.add_parser( + "select", + help="Select files under a root; every rejection carries a typed receipt.", + ) + se.add_argument("--root", type=Path, default=Path.cwd()) + se.add_argument( + "--suffix", + dest="suffixes", + action="append", + default=None, + help="keep only files with this suffix (repeatable, e.g. --suffix .md)", + ) + se.add_argument( + "--max-files", + type=int, + default=None, + help="file budget; paths beyond it get over-budget receipts", + ) + se.add_argument("--json", action="store_true") + + +def _add_viz_parser(sub) -> None: + v = sub.add_parser("viz", help="Render the dependency graph (html/svg/mermaid).") + v.add_argument("--root", type=Path, default=Path.cwd()) + v.add_argument( + "--format", choices=["html", "svg", "mermaid", "all"], default="html" + ) + v.add_argument("--focus", default=None) + v.add_argument("--no-external", action="store_true") + v.add_argument("--out", default=None) + v.add_argument("--out-dir", default=None) + + +def _add_atlas_parser(sub) -> None: + a = sub.add_parser("atlas", help="Two-layer code + knowledge map (repos + docs).") + a.add_argument("--root", type=Path, default=Path.cwd()) + a.add_argument("--json", action="store_true") + a.add_argument("--format", choices=["html"], default=None) + a.add_argument("--out", default=None) + a.add_argument("--no-external", action="store_true") + + +def _add_internals_parser(sub) -> None: + i = sub.add_parser("internals", help="Intra-repo module dependency graph.") + i.add_argument("--root", type=Path, default=Path.cwd()) + i.add_argument("--json", action="store_true") + i.add_argument("--cycles", action="store_true") + + +def _add_internals_symbols_parser(sub) -> None: + s = sub.add_parser( + "internals-symbols", + help="Symbol-level call/reference graph for one repo (Python AST-exact " + "within-module; best-effort, honestly-labeled cross-module).", + ) + s.add_argument("--root", type=Path, default=Path.cwd()) + s.add_argument("--json", action="store_true") + s.add_argument( + "--coverage", + action="store_true", + help="Report only the coverage summary (symbols, resolved/unresolved " + "calls, parse errors, dynamic dispatch).", + ) + + +def _add_symbols_parser(sub) -> None: + s = sub.add_parser( + "symbols", + help="Navigate the symbol graph: go-to-definition, find-references, and " + "find-implementations for a symbol, each hop with file:line evidence.", + ) + s.add_argument( + "query", + help="symbol id (module::name or Class::method) or a bare name", + ) + s.add_argument("--root", type=Path, default=Path.cwd()) + s.add_argument("--json", action="store_true") + s.add_argument( + "--def", dest="definition", action="store_true", + help="Only go-to-definition (matching definitions with file:line).", + ) + s.add_argument( + "--refs", dest="references", action="store_true", + help="Only find-references (resolved callers; unresolved refs listed " + "separately, never as callers).", + ) + s.add_argument( + "--impls", dest="implementations", action="store_true", + help="Only find-implementations (in-repo subclasses of a class or " + "overrides of a method). No flag reports all three sections.", + ) + + +def _add_check_parser(sub) -> None: + ck = sub.add_parser( + "check", + help="Check structure against the declared [architecture] criterion.", + ) + ck.add_argument("--root", type=Path, default=Path.cwd()) + ck.add_argument( + "--internals", action="store_true", help="Include intra-repo module checks." + ) + ck.add_argument( + "--freshness", + action="store_true", + help="Stamp the certificate with a workspace content fingerprint.", + ) + ck.add_argument("--json", action="store_true") + ck.add_argument("--config", type=Path, default=None) + + +def _add_snapshot_parser(sub) -> None: + sn = sub.add_parser( + "snapshot", help="Write a canonical graph snapshot for drift diffing." + ) + sn.add_argument("--root", type=Path, default=Path.cwd()) + sn.add_argument("--out", type=Path, required=True) + + +def _add_drift_parser(sub) -> None: + dr = sub.add_parser("drift", help="Diff two snapshots into a drift report.") + dr.add_argument("--from", dest="from_snap", type=Path, required=True) + dr.add_argument("--to", dest="to_snap", type=Path, required=True) + dr.add_argument("--json", action="store_true") + + +def _add_router_parser(sub) -> None: + rt = sub.add_parser( + "router", + help="Emit a workspace map (CLAUDE.md/AGENTS.md) from the graph and docs.", + ) + rt.add_argument("--root", type=Path, default=Path.cwd()) + rt.add_argument("--out", default=None) + rt.add_argument("--max-docs", type=int, default=500, + help="maximum doc-to-repo edges rendered in the router markdown") + rt.add_argument("--no-cache", action="store_true", + help="disable router text and per-repository graph caches for this run") + _add_budget_ms_arg(rt) + + +def _add_verify_parser(sub) -> None: + vf = sub.add_parser( + "verify", + help="Ground a structural claim against the graph " + "(MATCH/REFUTED/UNVERIFIABLE).", + ) + vf.add_argument("--root", type=Path, default=Path.cwd()) + vf.add_argument("--depends", default=None, help="claim 'A -> B' (A depends on B)") + vf.add_argument("--exists", default=None, help="claim that repo NAME exists") + vf.add_argument("--json", action="store_true") + + +def _add_freshness_parser(sub) -> None: + fr = sub.add_parser( + "freshness", + help="Has the workspace changed since a certificate was minted? (FRESH/STALE).", + ) + fr.add_argument( + "--cert", + type=Path, + required=True, + help="A certificate JSON carrying a freshness stamp (index check --freshness).", + ) + fr.add_argument("--root", type=Path, default=Path.cwd()) + fr.add_argument("--json", action="store_true") + + +def _add_invalidate_parser(sub) -> None: + inv = sub.add_parser( + "invalidate", + help="Diff the tree against a pinned fingerprint and name " + "exactly what the changes invalidate (FRESH/STALE).", + ) + inv.add_argument("--root", type=Path, default=Path.cwd()) + inv.add_argument( + "--pin", + type=Path, + default=None, + help="A pin JSON minted earlier with --out; emits the " + "index.invalidation/1 report against it.", + ) + inv.add_argument( + "--out", + type=Path, + default=None, + help="Mint a pin of the current tree to this file.", + ) + inv.add_argument("--json", action="store_true") + + +def _add_bench_parser(sub) -> None: + bn = sub.add_parser( + "bench", + help="Token economy: index's structural pack vs reading the source " + "it distills.", + ) + bn.add_argument("--root", type=Path, default=Path.cwd()) + bn.add_argument("--json", action="store_true") + bn.add_argument("--no-cache", action="store_true", + help="disable the workspace bench filesystem cache for this run") + + +def _add_serve_parser(sub) -> None: + sv = sub.add_parser( + "serve", + help="Local http.server that derives a repo's verified wiki on demand " + "from its forge path (consent-clean; robots.txt disallows indexing).", + ) + sv.add_argument( + "--host", + default="127.0.0.1", + help="interface to bind (default 127.0.0.1, loopback only)", + ) + sv.add_argument( + "--port", + type=int, + default=8000, + help="port to bind (default 8000; 0 picks an ephemeral port)", + ) + + +def _add_lsp_parser(sub) -> None: + lsp = sub.add_parser( + "lsp", + help="Start a stdio LSP server exposing go-to-definition and " + "find-references over the symbol graph (VSCode/Neovim/JetBrains). " + "Answers are evidence-backed file:line or honestly empty; a stale " + "workspace is detected, never silently answered.", + ) + lsp.add_argument("--root", type=Path, default=Path.cwd()) + lsp.add_argument( + "--trace", + choices=["off", "messages", "verbose"], + default="off", + help="LSP trace verbosity (reserved; currently a no-op placeholder).", + ) + + +def _add_router_job_parser(sub) -> None: + parser = sub.add_parser("router-job", help="Start and recover durable local router builds.") + actions = parser.add_subparsers(dest="action", required=True) + start = actions.add_parser("start", help="Start a complete background router build.") + start.add_argument("--root", type=Path, default=Path.cwd()) + start.add_argument("--max-docs", type=int, default=500) + start.add_argument("--budget-ms", type=int, default=0) + start.add_argument("--no-cache", action="store_true") + for action in ("status", "result", "cancel", "resume"): + actions.add_parser(action).add_argument("job_id") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="index", + description="Repository inventory maps + dependency graph + context packs.", + ) + parser.add_argument( + "--version", action="version", version=f"%(prog)s {__version__}" + ) + sub = parser.add_subparsers(dest="cmd") + + _add_telos_parser(sub, "status", "emit Index's Project Telos operator-spine status") + _add_telos_parser(sub, "doctor", "check Index's operator-spine readiness") + _add_telos_parser(sub, "demo", "show Index's operator-spine demo command") + _add_map_args( + sub.add_parser("map", help="Write the repository inventory map (default).") + ) + _add_graph_parser(sub) + _add_context_parser(sub) + _add_context_envelope_parser(sub) + _add_route_parser(sub) + _add_watch_parser(sub) + _add_lens_parser(sub) + _add_select_parser(sub) + _add_viz_parser(sub) + _add_atlas_parser(sub) + _add_workbench_parser(sub) + add_wiki_parser(sub) + _add_internals_parser(sub) + _add_internals_symbols_parser(sub) + _add_symbols_parser(sub) + _add_check_parser(sub) + _add_snapshot_parser(sub) + _add_drift_parser(sub) + _add_router_parser(sub) + _add_router_job_parser(sub) + _add_verify_parser(sub) + _add_freshness_parser(sub) + _add_invalidate_parser(sub) + _add_bench_parser(sub) + _add_serve_parser(sub) + _add_lsp_parser(sub) + sub.add_parser( + "mcp", + help="Serve the MCP-shaped stdio protocol face (JSON-RPC over stdin/stdout).", + ) + return parser diff --git a/client-plugin/server/src/index_graph/client_mcp.py b/client-plugin/server/src/index_graph/client_mcp.py new file mode 100644 index 0000000..8a2ad2f --- /dev/null +++ b/client-plugin/server/src/index_graph/client_mcp.py @@ -0,0 +1,184 @@ +"""Local client profile: launch-root confinement and explicit tool allowlist. + +This is a read-only convenience boundary, not an OS sandbox. Concurrent local +filesystem mutation is outside its threat model. Full CLI/MCP remains separate. +""" +from __future__ import annotations + +import argparse +import json +import os +import stat +import sys +from pathlib import Path + +MAX_FILE = 8_000_000 +MAX_ENTRIES = 20_000 +NAME = "index" +PACKAGE = "index_graph" + + +class ClientRefusal(ValueError): + def __init__(self, detail, code="PATH_DENIED"): + super().__init__(detail) + self.code = code + + +def confined(root: Path, value: object, *, tree=False) -> Path: + if not isinstance(value, str) or not value or chr(0) in value: + raise ClientRefusal("path must be a non-empty string") + # Reject Windows network/device/ADS paths before resolving or accessing them. + text = value.replace(chr(92), "/") + if text.startswith("//") or ":" in text[2:] or (":" in text and not (len(text) > 2 and text[1:3] == ":/")): + raise ClientRefusal("network, device and alternate-stream paths are not permitted") + candidate = Path(value) + if not candidate.is_absolute(): + candidate = root / candidate + path = candidate.resolve(strict=True) + if path != root and root not in path.parents: + raise ClientRefusal("path is outside the launch workspace") + for part in [candidate, *candidate.parents]: + if part == root.parent: + break + info = part.lstat() + if stat.S_ISLNK(info.st_mode) or getattr(info, "st_file_attributes", 0) & 0x400: + raise ClientRefusal("links and reparse points are not permitted") + if path.is_file() and path.stat().st_size > MAX_FILE: + raise ClientRefusal("file exceeds local client limit") + if tree and path.is_dir(): + count = 0 + for folder, dirs, files in os.walk(path, followlinks=False): + dirs[:] = [d for d in dirs if d not in {".git", ".venv", "node_modules", "__pycache__"}] + for name in [*dirs, *files]: + count += 1 + if count > MAX_ENTRIES: + raise ClientRefusal("workspace exceeds local client entry limit") + confined(root, str(Path(folder) / name)) + return path + + +def handle(req, root, state=None): + if not isinstance(req, dict): + return {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "invalid request"}} + if "id" not in req: + return None + response = {"jsonrpc": "2.0", "id": req["id"]} + method = req.get("method") + if method == "initialize": + from index_graph import __version__ + response["result"] = {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, + "serverInfo": {"name": NAME + "-local", "version": __version__}} + elif method == "ping": + response["result"] = {} + elif method == "tools/list": + response["result"] = {"tools": definitions(state)} + elif method == "tools/call": + try: + params = req.get("params") or {} + args = params.get("arguments") or {} + name = params.get("name") + definition = next((d for d in definitions(state) if d["name"] == name), None) + if definition is None: + raise ClientRefusal("tool requires the separately configured full MCP surface", "TOOL_NOT_GRANTED") + if not isinstance(args, dict) or set(args) - set(definition["inputSchema"]["properties"]): + raise ClientRefusal("unsupported arguments cannot grant permissions", "ARGUMENTS_DENIED") + missing = set(definition["inputSchema"].get("required", [])) - set(args) + if missing: + raise ClientRefusal("missing required arguments", "ARGUMENTS_DENIED") + data = invoke(name, dict(args), root, state) + response["result"] = {"content": [{"type": "text", "text": data}], "isError": False} + except Exception as exc: # noqa: BLE001 - MCP returns typed engine errors. + response["result"] = {"content": [{"type": "text", "text": json.dumps( + {"code": exc.code if isinstance(exc, ClientRefusal) else "LOCAL_PROFILE_ERROR", + "detail": str(exc)})}], "isError": True} + else: + response["error"] = {"code": -32601, "message": "method not found"} + return response + + +def install_process_boundary(): + """Deny process and network actions in this stdlib profile, including Git.""" + def audit(event, args): + if event.startswith("socket.") or event in {"subprocess.Popen", "os.system", "os.posix_spawn", "os.posix_spawnp", + "os.exec", "os.spawn", "socket.connect", "socket.connect_ex", + "socket.bind", "socket.getaddrinfo"}: + raise PermissionError("local client profile does not grant processes or network") + sys.addaudithook(audit) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--workspace", required=True, help="explicit local directory this client may read") + parser.add_argument("--state-directory", help="existing private directory for cache and owned jobs") + args = parser.parse_args(argv) + root_arg = Path(args.workspace).absolute() + root = confined(root_arg, str(root_arg)) + if not root.is_dir(): + parser.error("workspace must be a directory") + state = None + if args.state_directory: + from .client_state import validate_state + if not Path(args.state_directory).is_absolute(): + parser.error("state directory must be an absolute path or empty") + state = validate_state(Path(args.state_directory).absolute()) + install_process_boundary() + # The local profile does not read permission grants from the environment. + for line in sys.stdin: + if len(line) > MAX_FILE: + return 2 + try: + response = handle(json.loads(line), root, state) + except json.JSONDecodeError: + response = {"jsonrpc": "2.0", "id": None, + "error": {"code": -32700, "message": "parse error"}} + if response is not None: + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return 0 + +def definitions(state=None): + from index_graph.mcp import _tool_defs + result = [d for d in _tool_defs() if d["name"] in { + "index.map", "index.select", "index.symbol-graph", + "index.symbol-definition", "index.symbol-references", "index.symbol-implementations"}] + for tool in result: + if tool["name"] == "index.map": + tool["inputSchema"] = {"type": "object", "properties": {"root": {"type": "string"}}, "required": ["root"]} + if state is not None: + tool["inputSchema"]["properties"]["no_cache"] = {"type": "boolean"} + else: + # Without a state directory this profile's map takes no resume_state + # and writes nothing. + tool["annotations"] = {**tool["annotations"], "readOnlyHint": True} + if state is not None: + from .mcp import annotate + from .router_job_surface import tool_definitions + result += [annotate(d) for d in tool_definitions() + if d["name"].rsplit(".", 1)[-1] in {"status", "result", "cancel"}] + return result + + +def invoke(name, args, root, state=None): + if state is not None: + from .client_state import invoke_state, validate_state + validate_state(state) + if name.startswith("index.router.job."): + return invoke_state(name, args, root, state) + path = confined(root, args["root"], tree=True) + if not path.is_dir(): + raise ClientRefusal("root must be a directory") + args["root"] = str(path) + if name == "index.map": + from index_graph import __version__ + from index_graph.config import default_config + from index_graph.scan import build_map + # No workspace-provided configuration, resume state or persistent caches. + if state is not None: + return invoke_state(name, args, root, state) + return json.dumps(build_map(path, default_config(), __version__).to_json()) + from index_graph.mcp import call_tool + return call_tool(name, args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/client-plugin/server/src/index_graph/client_state.py b/client-plugin/server/src/index_graph/client_state.py new file mode 100644 index 0000000..1a1d6b2 --- /dev/null +++ b/client-plugin/server/src/index_graph/client_state.py @@ -0,0 +1,56 @@ +"""Explicit client-owned persistence using the existing cache/map/job engines.""" +from __future__ import annotations + +import json +import os +import uuid +from pathlib import Path + +from .client_mcp import MAX_ENTRIES, ClientRefusal, confined + + +def validate_state(state: Path) -> Path: + state = confined(state, str(state), tree=True) + if not state.is_dir(): + raise ClientRefusal("state directory must exist") + # Persistence is private. A hard-linked output could mutate a file elsewhere. + count = 0 + for folder, dirs, files in os.walk(state, followlinks=False): + for name in [*dirs, *files]: + count += 1 + if count > MAX_ENTRIES: + raise ClientRefusal("state directory exceeds local client entry limit") + path = confined(state, str(Path(folder) / name)) + if path.is_file() and path.stat().st_nlink != 1: + raise ClientRefusal("state files cannot have hard links") + return state + + +def invoke_state(name, args, root, state): + if name == "index.map": + from . import __version__ + from .cache import cached_text + from .config import default_config + from .scan import build_map + for key in ("no_cache",): + if key in args and type(args[key]) is not bool: + raise ClientRefusal(f"{key} must be boolean", "ARGUMENTS_DENIED") + path = confined(root, args["root"], tree=True) + return cached_text("index.map", path, {"version": __version__}, + lambda: json.dumps(build_map(path, default_config(), __version__).to_json()), + enabled=not args.get("no_cache", False), cache_root=state / "cache") + from . import router_jobs + action = name.rsplit(".", 1)[-1] + operations = {"status": router_jobs.read_router_job_status, + "result": router_jobs.read_router_job_result, + "cancel": router_jobs.cancel_router_job} + if action not in operations: + raise ClientRefusal("starting or resuming workers requires a separate process grant", "TOOL_NOT_GRANTED") + job_id = str(uuid.UUID(args["job_id"])) + job = confined(state, str(state / "jobs" / job_id), tree=True) + request_path = confined(state, str(job / "request.json")) + request = json.loads(request_path.read_text(encoding="utf-8")) + if request.get("schema") != "index.router-job-request/v1" or request.get("job_id") != job_id: + raise ClientRefusal("invalid owned job request") + confined(root, request.get("root"), tree=True) + return json.dumps(operations[action](job_id, job_root=state / "jobs")) diff --git a/client-plugin/server/src/index_graph/config.py b/client-plugin/server/src/index_graph/config.py new file mode 100644 index 0000000..634cfa0 --- /dev/null +++ b/client-plugin/server/src/index_graph/config.py @@ -0,0 +1,157 @@ +"""Configuration: .index.toml parsing, neutral defaults, glob translation.""" + +from __future__ import annotations + +import os +import re +import sys +import tomllib +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from .arch.criteria import ArchitectureCriteria, parse_architecture + +DEFAULT_PRUNE_DIRS = frozenset({ + ".git", ".mypy_cache", ".pytest_cache", ".ruff_cache", + "__pycache__", ".venv", "venv", "venvs", "env", + "node_modules", "site-packages", "lib64", ".tox", ".eggs", + "build", "dist", ".cache", ".playwright-mcp", + ".warden-safe-cache", ".next", ".turbo", + "target", "coverage", ".coverage", ".nyc_output", + ".parcel-cache", ".svelte-kit", ".angular", ".expo", + ".gradle", ".idea", ".vscode", ".yarn", ".pnpm-store", + ".terraform", "out", +}) +DEFAULT_MARKERS = ( + "README.md", "AGENTS.md", "CLAUDE.md", "pyproject.toml", "package.json", + "Cargo.toml", "CMakeLists.txt", "Makefile", "requirements.txt", +) +PUBLIC_HOSTS = frozenset({ + "github.com", "gitlab.com", "bitbucket.org", "codeberg.org", "git.sr.ht", +}) +_KNOWN_TOP = frozenset({"rule", "scan", "privacy", "output", "architecture"}) + + +def _default_jobs() -> int: + return min(32, (os.cpu_count() or 4) * 5) + + +def glob_to_regex(pattern: str) -> str: + """Translate a path glob to an anchored regex. + + `*` matches within a segment, `**` across segments, `/**` makes the + separator optional so `public/**` also matches `public`. + """ + out: list[str] = [] + i, n = 0, len(pattern) + while i < n: + if pattern.startswith("/**", i): + out.append("(/.*)?") + i += 3 + elif pattern.startswith("**", i): + out.append(".*") + i += 2 + elif pattern[i] == "*": + out.append("[^/]*") + i += 1 + else: + out.append(re.escape(pattern[i])) + i += 1 + return "^" + "".join(out) + "$" + + +@dataclass(frozen=True) +class Rule: + pattern: str + class_: str + regex: re.Pattern = field(init=False, compare=False, repr=False) + + def __post_init__(self) -> None: + object.__setattr__(self, "regex", re.compile(glob_to_regex(self.pattern))) + + +@dataclass(frozen=True) +class Config: + rules: tuple[Rule, ...] = () + extra_prune: frozenset[str] = frozenset() + markers: tuple[str, ...] = DEFAULT_MARKERS + jobs: int = field(default_factory=_default_jobs) + descend_into_repos: bool = False + include_root_repo: bool = False + omit_origin_classes: frozenset[str] = frozenset() + portable: bool = True + annotations: dict[str, Any] = field(default_factory=dict) + architecture: ArchitectureCriteria = field(default_factory=ArchitectureCriteria) + + @property + def prune(self) -> frozenset[str]: + return DEFAULT_PRUNE_DIRS | self.extra_prune + + +def default_config() -> Config: + return Config() + + +def load_config(path: Path | None, root: Path) -> Config: + if path is None: + candidates = (root / ".index.toml", root / ".repomap.toml") + path = next((candidate for candidate in candidates if candidate.exists()), None) + if path is None: + return default_config() + elif not path.exists(): + raise SystemExit(f"config not found: {path}") + try: + text = path.read_text(encoding="utf-8-sig", errors="replace") # tolerate BOM and legacy bytes + data = tomllib.loads(text) + except tomllib.TOMLDecodeError as exc: + raise SystemExit(f"{path}: invalid TOML: {exc}") from exc + except OSError as exc: + raise SystemExit(f"{path}: cannot read: {exc}") from exc + return _build_config(data, path) + + +def _build_config(data: dict[str, Any], path: Path) -> Config: + rules: list[Rule] = [] + for idx, item in enumerate(data.get("rule", [])): + if "pattern" not in item or "class" not in item: + raise SystemExit(f"{path}: rule[{idx}] requires 'pattern' and 'class'") + rules.append(Rule(str(item["pattern"]), str(item["class"]))) + + scan = data.get("scan", {}) + jobs = scan.get("jobs", _default_jobs()) + if not isinstance(jobs, int) or jobs < 1: + raise SystemExit(f"{path}: [scan] jobs must be a positive integer") + extra_prune = frozenset(str(d) for d in scan.get("prune", [])) + markers = tuple(scan["markers"]) if "markers" in scan else DEFAULT_MARKERS + descend_into_repos = scan.get("descend_into_repos", False) + if not isinstance(descend_into_repos, bool): + raise SystemExit(f"{path}: [scan] descend_into_repos must be a boolean") + include_root_repo = scan.get("include_root_repo", False) + if not isinstance(include_root_repo, bool): + raise SystemExit(f"{path}: [scan] include_root_repo must be a boolean") + + omit = frozenset(str(c) for c in data.get("privacy", {}).get("omit_origin_classes", [])) + + output = data.get("output", {}) + portable = bool(output.get("portable", True)) + annotations = dict(output.get("annotations", {})) + + architecture = parse_architecture(data.get("architecture", {})) + + for key in data: + if key not in _KNOWN_TOP: + print(f"{path}: warning: unknown config key '{key}'", file=sys.stderr) + + return Config( + rules=tuple(rules), + extra_prune=extra_prune, + markers=markers, + jobs=jobs, + descend_into_repos=descend_into_repos, + include_root_repo=include_root_repo, + omit_origin_classes=omit, + portable=portable, + annotations=annotations, + architecture=architecture, + ) diff --git a/client-plugin/server/src/index_graph/context/__init__.py b/client-plugin/server/src/index_graph/context/__init__.py new file mode 100644 index 0000000..a4b9e40 --- /dev/null +++ b/client-plugin/server/src/index_graph/context/__init__.py @@ -0,0 +1 @@ +"""Synthesis context-pack and context-envelope renderers.""" diff --git a/client-plugin/server/src/index_graph/context/envelope.py b/client-plugin/server/src/index_graph/context/envelope.py new file mode 100644 index 0000000..a673593 --- /dev/null +++ b/client-plugin/server/src/index_graph/context/envelope.py @@ -0,0 +1,677 @@ +"""Budgeted context envelopes for large-codebase agent work.""" +from __future__ import annotations + +import hashlib +import json +import math +from pathlib import Path + +from ..freshness import SCHEMA as FRESHNESS_SCHEMA, workspace_fingerprint +from ..graph.build import DependencyGraph +from .focus import resolve_focus +from .pack import closure, focus_subgraph, preservation, to_json + +SCHEMA = "project-telos.context-envelope/v1" +TOOL = "index.context.envelope" +BYTES_PER_TOKEN = 4 +FRESHNESS_ENVELOPE_SCHEMA = "index.context-envelope-freshness/v1" + + +def build_context_envelope( + graph: DependencyGraph, + *, + root: Path | str, + token_budget: int, + focus: str | None = None, + hops: int | None = None, + browser_evidence_refs: list[dict] | None = None, + bounded_output: bool = False, + bounded_output_transport: str = "canonical_json", + mcp_response_id: object | None = None, +) -> dict: + """Return a deterministic, receipt-backed context packet within ``token_budget``.""" + if token_budget < 1: + raise ValueError("token_budget must be positive") + source_graph = graph + preserved = None + candidate_repo_count = len(source_graph.repos) + if focus: + names = {node.name for node in graph.repos} + # an unresolvable selector raises FocusRejection (a ValueError) whose + # .receipt is the typed index.focus-rejection/v1 contract + focus = resolve_focus(focus, names) + keep = closure(list(graph.edges), focus, hops=hops) + preserved = preservation(list(graph.edges), keep, focus, hops) + graph = focus_subgraph(graph, keep) + pack = to_json(graph) + pack_hash = _sha(pack) + retained: list[dict] = [] + omitted: list[dict] = [] + approx_tokens = _base_tokens(pack) + source_refs = _source_refs(graph, Path(root).resolve()) + for repo in _ranked_repos(pack, focus): + item = _repo_item(repo, pack, source_refs.get(repo["name"], [])) + cost = _approx_tokens(item) + if approx_tokens + cost <= token_budget or not retained: + retained.append(item) + approx_tokens += cost + else: + omitted.append(_omitted(repo["name"], "budget_exceeded", cost)) + omitted.extend(_focus_omissions(source_graph, graph)) + omitted = _dedupe_omitted(omitted) + failure_codes = ["budget_exceeded"] if any( + item["reason"] == "budget_exceeded" for item in omitted + ) else [] + # if what was KEPT still exceeds the budget (a forced-first item larger + # than the whole budget, retained because dropping it would return + # nothing), that is a distinct honest fact from budget_exceeded omissions: + # the request could not be met even after omitting everything omittable. + # Name it rather than hiding it behind a min() cap on the reported cost. + if approx_tokens > token_budget: + failure_codes.append("budget_overflow") + verdict = "UNVERIFIABLE" if failure_codes else "MATCH" + fresh = _freshness(source_graph, retained) + envelope = { + "schema": SCHEMA, + "tool": TOOL, + "verification_verdict": verdict, + "failure_codes": failure_codes, + "root": str(Path(root)), + "focus": {"repo": focus, "hops": hops}, + "budget": { + "token_budget": token_budget, + # approx_tokens bounds the RETAINED selection (base + kept items): + # that is what the budget gates. It is NOT the whole emitted packet, + # which also carries omitted/preserved/freshness/selection metadata; + # packet_approx_tokens (set below) reports the full serialized cost + # so neither number is read as the other. + "approx_tokens": approx_tokens, + "bytes_per_token": BYTES_PER_TOKEN, + }, + "selection": _selection( + mode="focused" if focus else "workspace", + candidate_repo_count=candidate_repo_count, + selected_repo_count=len(graph.repos), + retained=retained, + omitted=omitted, + ), + "context_policy": { + "mode": "lossless_by_reference", + "raw_payload_policy": "source_refs_only", + "omission_policy": "explicit_failure_codes", + }, + "browser_evidence_refs": _browser_evidence_refs(browser_evidence_refs or []), + "retained": retained, + "omitted": omitted, + "preserved": preserved, + "freshness": fresh, + "receipts": [{"kind": "graph-pack", "sha256": pack_hash, "schema": "index.context/graph-pack"}], + "privacy": {"raw_source_included": False, "source_refs_only": True}, + "recheck": { + "command": "index context-envelope --json", + "graph_pack_sha256": pack_hash, + "freshness_root_sha256": fresh["workspace_root_sha256"], + }, + } + # the whole emitted packet's approximate token cost (retained content plus + # all the metadata the caller receives), re-derivable from the dict itself + packet_bytes = len(json.dumps(envelope, sort_keys=True).encode("utf-8")) + envelope["budget"]["packet_approx_tokens"] = packet_bytes // BYTES_PER_TOKEN + if bounded_output: + envelope = _apply_bounded_output( + envelope, + token_budget=token_budget, + root=Path(root), + focus=focus, + hops=hops, + transport=bounded_output_transport, + mcp_response_id=mcp_response_id, + ) + return envelope + + +def _apply_bounded_output( + envelope: dict, + *, + token_budget: int, + root: Path, + focus: str | None, + hops: int | None, + transport: str, + mcp_response_id: object | None, +) -> dict: + """Compact an envelope so the serialized tool response fits the budget. + + The default contract budgets the retained selection. This opt-in contract + budgets the emitted JSON packet itself, measured by serialized UTF-8 bytes + divided by ``BYTES_PER_TOKEN`` and rounded up. That is a deterministic + transport-size heuristic, not model-tokenizer output. + """ + if token_budget < 2: + raise ValueError("bounded_output budget too small to emit overflow receipt") + envelope["context_policy"]["output_policy"] = "bounded_packet" + envelope["budget"]["output_policy"] = "bounded_packet" + envelope["budget"]["pre_compaction_packet_approx_tokens"] = _packet_tokens( + envelope, ceiling=True, transport=transport, mcp_response_id=mcp_response_id) + envelope["source_ref_omissions"] = [] + _set_packet_measurement(envelope, transport=transport, mcp_response_id=mcp_response_id) + if envelope["budget"]["packet_approx_tokens"] <= token_budget: + return envelope + + _compact_source_refs( + envelope, + root=root, + focus=focus, + hops=hops, + token_budget=token_budget, + ) + _add_failure_code(envelope, "output_budget_exceeded") + envelope["verification_verdict"] = "UNVERIFIABLE" + _refresh_selection(envelope) + _set_packet_measurement(envelope, transport=transport, mcp_response_id=mcp_response_id) + if envelope["budget"]["packet_approx_tokens"] <= token_budget: + return envelope + + minimal = _minimal_bounded_overflow_receipt( + envelope, + root=root, + focus=focus, + hops=hops, + token_budget=token_budget, + ) + _set_packet_measurement(minimal, transport=transport, mcp_response_id=mcp_response_id) + if minimal["budget"]["packet_approx_tokens"] > token_budget: + raise ValueError("bounded_output budget too small to emit overflow receipt") + return minimal + + +def _compact_source_refs( + envelope: dict, + *, + root: Path, + focus: str | None, + hops: int | None, + token_budget: int, +) -> None: + retained_hashes = envelope.get("freshness", {}).get("retained_repo_sha256", {}) + omissions = [] + pre_compaction = envelope["budget"]["pre_compaction_packet_approx_tokens"] + for item in envelope.get("retained", []): + refs = list(item.get("source_refs") or []) + if not refs: + continue + item["source_refs"] = [] + omissions.append(_source_ref_omission( + item, + original_source_refs=refs, + included_source_refs=[], + root=root, + focus=focus, + hops=hops, + token_budget=token_budget, + pre_compaction_packet_approx_tokens=pre_compaction, + retained_repo_sha256=retained_hashes.get(item.get("name")), + )) + envelope["source_ref_omissions"] = omissions + + +def _source_ref_omission( + item: dict, + *, + original_source_refs: list[dict], + included_source_refs: list[dict], + root: Path, + focus: str | None, + hops: int | None, + token_budget: int, + pre_compaction_packet_approx_tokens: int, + retained_repo_sha256: str | None, +) -> dict: + omitted = len(original_source_refs) - len(included_source_refs) + args = { + "root": str(root), + "budget": max(token_budget, pre_compaction_packet_approx_tokens), + "bounded_output": False, + } + if focus is not None: + args["focus"] = focus + if hops is not None: + args["hops"] = hops + reissue_args = { + "root": str(root), + "budget": token_budget, + "bounded_output": True, + } + if focus is not None: + reissue_args["focus"] = focus + if hops is not None: + reissue_args["hops"] = hops + return { + "repo": item["name"], + "reason": "source_refs_omitted_to_bound_serialized_packet", + "failure_code": "output_budget_exceeded", + "total_source_refs": len(original_source_refs), + "included_source_refs": len(included_source_refs), + "omitted_source_refs": omitted, + "source_refs_sha256": _sha(original_source_refs), + "included_source_refs_sha256": _sha(included_source_refs), + "approx_tokens": _approx_tokens(original_source_refs), + "expand": {"tool": TOOL, "arguments": args}, + "reissue": {"tool": TOOL, "arguments": reissue_args}, + "provenance": { + "source_ref_schema": "project-telos.source-ref/v1", + "retained_repo_sha256": retained_repo_sha256, + }, + } + + +def _minimal_bounded_overflow_receipt( + envelope: dict, + *, + root: Path, + focus: str | None, + hops: int | None, + token_budget: int, +) -> dict: + pre_compaction = envelope["budget"]["pre_compaction_packet_approx_tokens"] + omitted_names = [item["name"] for item in envelope.get("retained", [])] + args = {"root": str(root), "budget": max(token_budget, pre_compaction), + "bounded_output": False} + if focus is not None: + args["focus"] = focus + if hops is not None: + args["hops"] = hops + preexisting_omitted = [dict(item) for item in envelope.get("omitted", [])] + retained_omitted = [ + { + "name": name, + "reason": "bounded_output_minimal_receipt", + "failure_code": "output_budget_exceeded", + "approx_tokens": 0, + "expand": {"tool": TOOL, "arguments": args}, + } + for name in omitted_names + ] + omitted = _dedupe_omitted(preexisting_omitted + retained_omitted) + omitted_failure_codes = sorted( + {"budget_overflow", "output_budget_exceeded"} + | {str(item.get("failure_code", "unknown")) for item in omitted} + ) + return { + "schema": SCHEMA, + "tool": TOOL, + "verification_verdict": "UNVERIFIABLE", + "failure_codes": ["budget_overflow", "output_budget_exceeded"], + "root": str(root), + "focus": {"repo": focus, "hops": hops}, + "budget": { + "token_budget": token_budget, + "approx_tokens": 0, + "bytes_per_token": BYTES_PER_TOKEN, + "output_policy": "bounded_packet", + "pre_compaction_packet_approx_tokens": pre_compaction, + }, + "selection": { + "mode": "focused" if focus else "workspace", + "candidate_repos": envelope.get("selection", {}).get("candidate_repos", 0), + "selected_repos": envelope.get("selection", {}).get("selected_repos", 0), + "retained_repos": 0, + "omitted_repos": len(omitted), + "retained_names": [], + "omitted_failure_codes": omitted_failure_codes, + }, + "retained": [], + "omitted": omitted, + "source_ref_omissions": envelope.get("source_ref_omissions", []), + } + + +def _refresh_selection(envelope: dict) -> None: + retained = envelope.get("retained", []) + omitted = envelope.get("omitted", []) + envelope["selection"] = _selection( + mode=envelope["selection"]["mode"], + candidate_repo_count=envelope["selection"]["candidate_repos"], + selected_repo_count=envelope["selection"]["selected_repos"], + retained=retained, + omitted=omitted, + ) + codes = set(envelope["selection"].get("omitted_failure_codes", [])) + codes.update(item["failure_code"] for item in envelope.get("source_ref_omissions", [])) + envelope["selection"]["omitted_failure_codes"] = sorted(codes) + + +def _add_failure_code(envelope: dict, code: str) -> None: + codes = envelope.setdefault("failure_codes", []) + if code not in codes: + codes.append(code) + + +def _set_packet_measurement( + envelope: dict, + *, + transport: str = "canonical_json", + mcp_response_id: object | None = None, +) -> None: + budget = envelope["budget"] + prior: tuple[int | None, int | None] = (None, None) + for _ in range(8): + serialized_bytes = _packet_bytes( + envelope, transport=transport, mcp_response_id=mcp_response_id) + approx = max(1, math.ceil(serialized_bytes / BYTES_PER_TOKEN)) + budget["packet_measurement"] = { + "scope": _transport_scope(transport), + "method": _transport_method(transport), + "unit": "serialized_utf8_json_bytes_ceiling_div_4", + "boundary": _transport_boundary(transport), + "serialized_bytes": serialized_bytes, + } + budget["packet_approx_tokens"] = approx + current = (serialized_bytes, approx) + if current == prior: + return + prior = current + + +def _packet_bytes( + envelope: dict, + *, + transport: str = "canonical_json", + mcp_response_id: object | None = None, +) -> int: + if transport == "canonical_json": + text = json.dumps(envelope, sort_keys=True) + elif transport == "cli_json_stdout": + text = json.dumps(envelope, indent=2, sort_keys=True) + "\n" + elif transport == "mcp_jsonrpc_tool_response": + tool_text = json.dumps(envelope, indent=2, sort_keys=True) + response = { + "jsonrpc": "2.0", + "id": mcp_response_id, + "result": { + "content": [{"type": "text", "text": tool_text}], + "isError": False, + }, + } + text = json.dumps(response, sort_keys=True) + else: + raise ValueError(f"unknown bounded_output transport: {transport}") + return len(text.encode("utf-8")) + + +def _transport_scope(transport: str) -> str: + if transport == "canonical_json": + return "entire_serialized_context_envelope" + if transport == "cli_json_stdout": + return "cli_json_stdout" + if transport == "mcp_jsonrpc_tool_response": + return "mcp_jsonrpc_tool_response" + raise ValueError(f"unknown bounded_output transport: {transport}") + + +def _transport_method(transport: str) -> str: + if transport == "canonical_json": + return "json.dumps(sort_keys=True).encode('utf-8')" + if transport == "cli_json_stdout": + return ( + "json.dumps(indent=2, sort_keys=True) + LF newline, " + "written as exact utf-8 bytes" + ) + if transport == "mcp_jsonrpc_tool_response": + return "json.dumps(JSON-RPC tool response, sort_keys=True).encode('utf-8')" + raise ValueError(f"unknown bounded_output transport: {transport}") + + +def _transport_boundary(transport: str) -> str: + if transport == "canonical_json": + return ( + "Approximate serialized-byte budget for the canonical Python envelope JSON; " + "not model-tokenizer output." + ) + if transport == "cli_json_stdout": + return ( + "Approximate serialized-byte budget for the emitted CLI --json stdout, " + "including pretty JSON and trailing LF newline written as exact utf-8 bytes; " + "not model-tokenizer output." + ) + if transport == "mcp_jsonrpc_tool_response": + return ( + "Approximate serialized-byte budget for the MCP JSON-RPC tool response wrapper; " + "not model-tokenizer output." + ) + raise ValueError(f"unknown bounded_output transport: {transport}") + + +def _packet_tokens( + envelope: dict, + *, + ceiling: bool, + transport: str = "canonical_json", + mcp_response_id: object | None = None, +) -> int: + raw = _packet_bytes(envelope, transport=transport, mcp_response_id=mcp_response_id) + if ceiling: + return max(1, math.ceil(raw / BYTES_PER_TOKEN)) + return max(1, raw // BYTES_PER_TOKEN) + + +def _ranked_repos(pack: dict, focus: str | None = None) -> list[dict]: + sal = pack.get("salience", {}) + return sorted( + pack.get("repos", []), + key=lambda repo: ( + repo["name"] != focus, + -sal.get(repo["name"], {}).get("in_degree", 0), + -sal.get(repo["name"], {}).get("out_degree", 0), + repo["name"], + ), + ) + + +def _repo_item(repo: dict, pack: dict, source_refs: list[dict]) -> dict: + sal = pack.get("salience", {}).get(repo["name"], {"in_degree": 0, "out_degree": 0}) + return { + "name": repo["name"], + "roles": pack.get("roles", {}).get(repo["name"], []), + "ecosystems": repo.get("ecosystems", []), + "description": repo.get("description", ""), + "salience": {"in_degree": sal.get("in_degree", 0), "out_degree": sal.get("out_degree", 0)}, + "source_refs": source_refs, + } + + +def _source_refs(graph: DependencyGraph, root: Path) -> dict[str, list[dict]]: + repo_paths = {node.name: Path(node.path) for node in graph.repos} + refs: dict[str, dict[tuple[str, int | None, str], dict]] = { + node.name: {} for node in graph.repos + } + for edge in graph.edges: + for signal in edge.signals: + if signal.evidence_file and edge.from_repo in repo_paths: + ref = _source_ref( + edge.from_repo, + repo_paths[edge.from_repo], + root, + signal.evidence_file, + signal.evidence_line, + signal.kind, + ) + refs[edge.from_repo].setdefault( + (ref["path"], ref["line"], ref["kind"]), ref) + for repo, path in repo_paths.items(): + if not refs[repo]: + fallback = _repo_ref(repo, path, root) + if fallback is not None: + refs[repo][(fallback["path"], fallback["line"], fallback["kind"])] = fallback + return { + repo: sorted(values.values(), key=lambda ref: (ref["path"], ref["line"] or 0, ref["kind"])) + for repo, values in refs.items() + } + + +def _repo_ref(repo: str, repo_path: Path, root: Path) -> dict | None: + for name in ("pyproject.toml", "package.json", "README.md", "README.rst", "README.txt"): + if (repo_path / name).is_file(): + return _source_ref(repo, repo_path, root, name, None, "repo") + return None + + +def _source_ref( + repo: str, + repo_path: Path, + root: Path, + evidence_file: str, + line: int | None, + kind: str, +) -> dict: + abs_path = (repo_path / evidence_file).resolve() + return { + "schema": "project-telos.source-ref/v1", + "repo": repo, + "repo_path": _rel(repo_path.resolve(), root), + "path": _rel(abs_path, root), + "kind": kind, + "line": line, + "sha256": _file_sha(abs_path), + "expand": { + "tool": "gather.docs", + "arguments": { + "path": _rel(abs_path, root), + "scope": TOOL, + }, + }, + } + + +def _focus_omissions(source: DependencyGraph, focused: DependencyGraph) -> list[dict]: + kept = {node.name for node in focused.repos} + return [_omitted(node.name, "outside_focus_or_budget", 0) + for node in source.repos if node.name not in kept] + + +def _omitted(name: str, reason: str, approx_tokens: int) -> dict: + return { + "name": name, + "reason": reason, + "failure_code": reason, + "approx_tokens": approx_tokens, + } + + +def _dedupe_omitted(items: list[dict]) -> list[dict]: + out: dict[str, dict] = {} + for item in items: + out.setdefault(item["name"], item) + return sorted(out.values(), key=lambda item: item["name"]) + + +def _selection( + *, + mode: str, + candidate_repo_count: int, + selected_repo_count: int, + retained: list[dict], + omitted: list[dict], +) -> dict: + return { + "mode": mode, + "candidate_repos": candidate_repo_count, + "selected_repos": selected_repo_count, + "retained_repos": len(retained), + "omitted_repos": len(omitted), + "retained_names": sorted(item["name"] for item in retained), + "omitted_failure_codes": sorted({item["failure_code"] for item in omitted}), + } + + +FRESHNESS_VERDICT_SCHEMA = "index.context-envelope-freshness-verdict/v1" + + +def verify_envelope_freshness(envelope: dict, graph: DependencyGraph) -> dict: + """Re-derive an envelope's freshness against the current workspace and return a verdict. + + The envelope binds itself to a workspace fingerprint (a root hash plus a per-repo hash + for every retained repo). This re-fingerprints the workspace from ``graph`` and confirms + those hashes still hold, so a cached envelope that went stale (the workspace changed + under it) is caught and the drifted repos are named -- the staleness failure mode of + cached context, made a check instead of a hope. MATCH when nothing moved, else DRIFT. + Re-derived from the workspace, never trusted from the envelope. Read-only.""" + fr = envelope.get("freshness") or {} + repo_paths = {node.name: Path(node.path) for node in graph.repos} + stamp = workspace_fingerprint(repo_paths) + root_ok = stamp["root"] == fr.get("workspace_root_sha256") + retained = fr.get("retained_repo_sha256") or {} + drifted = sorted(n for n, sha in retained.items() if stamp["repos"].get(n) != sha) + missing = sorted(n for n in retained if n not in stamp["repos"]) + fresh = root_ok and not drifted and not missing + return { + "schema": FRESHNESS_VERDICT_SCHEMA, + "verdict": "MATCH" if fresh else "DRIFT", + "fresh": fresh, + "workspace_root_ok": root_ok, + "drifted_repos": drifted, + "missing_repos": missing, + "expected_root_sha256": fr.get("workspace_root_sha256"), + "actual_root_sha256": stamp["root"], + "checked_repos": sorted(retained), + } + + +def _freshness(source_graph: DependencyGraph, retained: list[dict]) -> dict: + repo_paths = {node.name: Path(node.path) for node in source_graph.repos} + stamp = workspace_fingerprint(repo_paths) + retained_names = {item["name"] for item in retained} + retained_repo_sha256 = { + name: stamp["repos"][name] + for name in sorted(retained_names) + if name in stamp["repos"] + } + return { + "schema": FRESHNESS_ENVELOPE_SCHEMA, + "source_schema": FRESHNESS_SCHEMA, + "workspace_root_sha256": stamp["root"], + "repo_count": len(stamp["repos"]), + "retained_repo_sha256": retained_repo_sha256, + "recheck": { + "tool": "index.freshness", + "command": "index check --freshness; index freshness --cert CERT --root ROOT", + }, + } + + +def _browser_evidence_refs(refs: list[dict]) -> list[dict]: + allowed = ("ref", "schema", "mode", "hash", "verification", "side_effect") + return [ + {key: ref[key] for key in allowed if key in ref} + for ref in refs + if isinstance(ref, dict) + ] + + +def _base_tokens(pack: dict) -> int: + return _approx_tokens({ + "schema": SCHEMA, + "relations": len(pack.get("relations", [])), + "cycles": pack.get("cycles", []), + "warnings": pack.get("warnings", []), + }) + + +def _approx_tokens(value: object) -> int: + return max(1, len(json.dumps(value, sort_keys=True, separators=(",", ":"))) // BYTES_PER_TOKEN) + + +def _sha(value: object) -> str: + data = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(data).hexdigest() + + +def _file_sha(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _rel(path: Path, root: Path) -> str: + try: + return path.relative_to(root).as_posix() + except ValueError: + return path.as_posix() diff --git a/client-plugin/server/src/index_graph/context/focus.py b/client-plugin/server/src/index_graph/context/focus.py new file mode 100644 index 0000000..9bbc619 --- /dev/null +++ b/client-plugin/server/src/index_graph/context/focus.py @@ -0,0 +1,78 @@ +"""Typed focus rejection receipts (index.focus-rejection/v1). + +A free-form focus selector that resolves to no repo fails TYPED, following +the index.path-selection/v1 precedent: the receipt names the unresolved +selector, a reason code from a closed set, and a bounded candidate list, +so a host can recover (pick a candidate, widen the scope) instead of +parsing a bare error string. +""" +from __future__ import annotations + +from collections.abc import Iterable + +REJECTION_SCHEMA = "index.focus-rejection/v1" + +# The closed set of rejection reason codes. A new code is added here, +# never invented at a call site. +REASON_CODES = frozenset({ + "unresolved-focus", # the selector matches no repo in the workspace + "empty-workspace", # there are no repos to match against at all +}) + +CANDIDATE_LIMIT = 8 + + +def focus_rejection(selector: str, names: Iterable[str], + limit: int = CANDIDATE_LIMIT) -> dict: + """Build the typed rejection receipt for an unresolvable ``selector``. + + Candidates are bounded to ``limit``: case-insensitive near matches + first, then the remaining repo names in sorted order. ``truncated`` + declares when the bound cut the list, and ``candidate_count`` always + carries the full population, so the receipt never implies the shown + candidates are all there is. + """ + pool = sorted(set(names)) + lowered = selector.lower() + near = [n for n in pool if lowered in n.lower() or n.lower() in lowered] + rest = [n for n in pool if n not in near] + candidates = (near + rest)[:max(limit, 0)] + return { + "schema": REJECTION_SCHEMA, + "selector": selector, + "reason_code": "empty-workspace" if not pool else "unresolved-focus", + "candidates": candidates, + "candidate_count": len(pool), + "truncated": len(pool) > len(candidates), + } + + +class FocusRejection(ValueError): + """An unresolvable focus selector, carrying its typed receipt. + + Subclasses ValueError so existing callers that catch ValueError keep + working; new callers read ``.receipt`` for the typed contract. + """ + + def __init__(self, receipt: dict): + super().__init__(f"unknown focus repo: {receipt['selector']}") + self.receipt = receipt + + +def resolve_focus(selector: str, names: Iterable[str]) -> str: + """Return ``selector`` when it names a repo; raise FocusRejection otherwise.""" + pool = set(names) + if selector in pool: + return selector + raise FocusRejection(focus_rejection(selector, pool)) + + +def render_rejection(receipt: dict) -> str: + """One human-readable line for the CLI faces; the JSON receipt is the contract.""" + line = f"focus rejected: {receipt['selector']!r} ({receipt['reason_code']})" + if receipt["candidates"]: + line += "; candidates: " + ", ".join(receipt["candidates"]) + hidden = receipt["candidate_count"] - len(receipt["candidates"]) + if receipt["truncated"] and hidden > 0: + line += f" (+{hidden} more)" + return line diff --git a/client-plugin/server/src/index_graph/context/lens.py b/client-plugin/server/src/index_graph/context/lens.py new file mode 100644 index 0000000..fadccf3 --- /dev/null +++ b/client-plugin/server/src/index_graph/context/lens.py @@ -0,0 +1,120 @@ +"""Lens pack: the context envelope plus the data needed to REPLAY it live. + +The envelope is a receipt; the lens makes it visible. To let the page vary the +token budget without re-running Python, the pack carries exactly what the +envelope algorithm consumes: the ranked repo order, each item's approximate +token cost, and the base cost. The page replays the same greedy rule +(`envelope.py`: retain while base + costs fits, first item always retained) +over the same numbers, so what the slider shows is what the CLI would emit, +never an approximation of it. + +Honest scope: the replay varies BUDGET only. Focus/hops change the candidate +set itself and require a re-run; the pack records the focus it was built with. +""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +from ..graph.build import DependencyGraph +from .envelope import ( + _approx_tokens, + _base_tokens, + _ranked_repos, + _repo_item, + _source_refs, + build_context_envelope, +) +from .focus import resolve_focus +from .pack import closure, focus_subgraph, to_json + +SCHEMA = "project-telos.context-lens/v1" +TOOL = "index.context.lens" + + +def replay_retained(order: list[dict], base_tokens: int, token_budget: int) -> list[str]: + """The SAME greedy rule as build_context_envelope, over the pack's numbers. + This function is the contract the page's JS mirrors; the lens test holds + both against the real envelope output.""" + retained: list[str] = [] + approx = base_tokens + for item in order: + if approx + item["cost"] <= token_budget or not retained: + retained.append(item["name"]) + approx += item["cost"] + return retained + + +def replay_verdict(order: list[dict], base_tokens: int, token_budget: int) -> str: + """The verdict the envelope would emit at this budget, from the same numbers. + `order` is the focus-scoped candidate set, so any candidate NOT retained is a + budget drop (failure_code budget_exceeded) -> UNVERIFIABLE; else MATCH. The + page's JS mirrors exactly this so the pill never disagrees with the CLI.""" + kept = set(replay_retained(order, base_tokens, token_budget)) + return "UNVERIFIABLE" if len(kept) < len(order) else "MATCH" + + +def _plain(text: str) -> str: + """Reduce a repo's description (often raw README head: HTML tags, markdown + image/link syntax, badges) to a clean one-line plain string for display.""" + import re + + text = re.sub(r"<[^>]+>", " ", text) # HTML tags + text = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", text) # md images + text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text) # md links -> label + text = re.sub(r"[`*#>_|]+", " ", text) # md punctuation + return re.sub(r"\s+", " ", text).strip() + + +def build_lens_pack( + graph: DependencyGraph, + *, + root: Path | str, + token_budget: int, + focus: str | None = None, + hops: int | None = None, +) -> dict: + """Envelope + replay data + node metadata, one deterministic pack.""" + envelope = build_context_envelope( + graph, root=root, token_budget=token_budget, focus=focus, hops=hops) + scoped = graph + if focus: + names = {node.name for node in graph.repos} + resolved = resolve_focus(focus, names) + scoped = focus_subgraph( + graph, closure(list(graph.edges), resolved, hops=hops)) + pack = to_json(scoped) + refs = _source_refs(scoped, Path(root).resolve()) + order = [] + for repo in _ranked_repos(pack, envelope["focus"]["repo"]): + item = _repo_item(repo, pack, refs.get(repo["name"], [])) + order.append({ + "name": item["name"], + "cost": _approx_tokens(item), # cost from the FULL item (refs incl.) + "roles": item["roles"], + "description": _plain(item["description"])[:160], + "salience": item["salience"], + "source_refs": item["source_refs"], + }) + lens = { + "schema": SCHEMA, + "tool": TOOL, + "envelope": envelope, + "replay": { + "rule": "greedy-in-rank-order; retain while base+costs fit; first item always retained", + "base_tokens": _base_tokens(pack), + "order": order, + }, + "edges": [ + {"from": e["from"], "to": e["to"]} + for e in pack.get("relations", []) if not e.get("external") + ], + } + lens["receipt_sha256"] = _sha(lens) + return lens + + +def _sha(value: object) -> str: + data = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(data).hexdigest() diff --git a/client-plugin/server/src/index_graph/context/pack.py b/client-plugin/server/src/index_graph/context/pack.py new file mode 100644 index 0000000..1ea0b9f --- /dev/null +++ b/client-plugin/server/src/index_graph/context/pack.py @@ -0,0 +1,138 @@ +"""Render a DependencyGraph as the synthesis context pack (relations+roles+prose). + +No editorializing: every line traces to a data field or an evidence record. +""" +from __future__ import annotations + +from ..graph.build import DependencyGraph, RepoNode +from ..graph.edges import Edge +from ..graph.cycles import find_cycles, cycle_edge_keys +from ..graph.roles import salience_audit, structural_salience + + +def _marker_list(node: RepoNode) -> list[str]: + out = [] + if "entry" in node.markers: + out.append("entry") + if "published" in node.markers: + out.append("published") + return out + + +def render_text(graph: DependencyGraph, title: str) -> str: + L = [f"# Context pack: {title}", ""] + L.append("## Roles (project: roles, in/out degree)") + sal = structural_salience(list(graph.edges)) + for node in sorted(graph.repos, key=lambda n: n.name): + rs = ", ".join(graph.roles.get(node.name, ())) or "(none)" + s = sal.get(node.name, {"in_degree": 0, "out_degree": 0}) + L.append(f"- {node.name}: {rs} (in={s['in_degree']} out={s['out_degree']})") + L.append("") + L.append("## Relations (A -> B: signals [confidence])") + for e in graph.edges: + if e.external: + continue + kinds = "+".join(sorted({s.kind for s in e.signals})) + L.append(f"- {e.from_repo} -> {e.to_repo}: {kinds} [{e.confidence}]") + L.append("") + L.append("## External dependencies (A -> name)") + for e in graph.edges: + if e.external: + L.append(f"- {e.from_repo} -> {e.target_name}") + L.append("") + L.append("## Inventory (all projects, extracted description)") + for node in sorted(graph.repos, key=lambda n: n.name): + eco = "/".join(node.ecosystems) or "none" + L.append(f"- {node.name} [{eco}]: {node.description}") + L.append("") + if graph.warnings: + L.append(f"## Warnings ({len(graph.warnings)})") + for w in graph.warnings: + L.append(f"- {w}") + return "\n".join(L) + + +def to_json(graph: DependencyGraph) -> dict: + sal = structural_salience(list(graph.edges)) + marked = {n.name: _marker_list(n) for n in graph.repos if _marker_list(n)} + cycles = find_cycles(graph.edges) + cyc_keys = cycle_edge_keys(graph.edges, cycles) + relations = [{ + "from": e.from_repo, "to": e.to_repo, "target_name": e.target_name, + "external": e.external, "confidence": e.confidence, + "in_cycle": (e.from_repo, e.to_repo) in cyc_keys, + "signals": [{"kind": s.kind, "file": s.evidence_file, "line": s.evidence_line, + "raw": s.raw_spec} for s in e.signals], + } for e in graph.edges] + return { + "roles": {n.name: list(graph.roles.get(n.name, ())) for n in graph.repos}, + "relations": relations, + "cycles": [list(c) for c in cycles], + "salience": sal, + "salience_audit": salience_audit(sal, marked), + "repos": [{"name": n.name, "ecosystems": list(n.ecosystems), + "description": n.description, "markers": sorted(n.markers)} + for n in graph.repos], + "warnings": list(graph.warnings), + } + + +def closure(edges: list[Edge], focus: str, hops: int | None = None) -> set[str]: + """Nodes reachable from focus (bidirectionally). With hops=N, bounded to the + N-hop neighborhood; with hops=None, the whole connected component.""" + adj: dict[str, set[str]] = {} + for e in edges: + if e.external or e.to_repo is None: + continue + adj.setdefault(e.from_repo, set()).add(e.to_repo) + adj.setdefault(e.to_repo, set()).add(e.from_repo) + seen = {focus} + frontier = {focus} + depth = 0 + while frontier and (hops is None or depth < hops): + nxt: set[str] = set() + for n in frontier: + for m in adj.get(n, ()): + if m not in seen: + seen.add(m) + nxt.add(m) + frontier = nxt + depth += 1 + return seen + + +def preservation(edges: list[Edge], keep: set[str], focus: str, + hops: int | None) -> dict: + """State what a focused pack preserves and what it drops at the boundary. + + An internal edge with exactly one endpoint inside `keep` crosses the boundary + and is not carried. Stating this is the information-bottleneck discipline: the + pack declares what structural information it guarantees to preserve and what it + knowingly drops, so a compact pack never reads as if it were complete. + """ + dropped_nodes: set[str] = set() + dropped_edges: set[str] = set() + for e in edges: + if e.external or e.to_repo is None: + continue + a_in, b_in = e.from_repo in keep, e.to_repo in keep + if a_in != b_in: + dropped_nodes.add(e.to_repo if a_in else e.from_repo) + dropped_edges.add(f"{e.from_repo} -> {e.to_repo}") + return { + "focus": [focus], + "hops": hops, + "kept_nodes": len(keep), + "boundary": { + "dropped_nodes": sorted(dropped_nodes), + "dropped_edges": sorted(dropped_edges), + }, + } + + +def focus_subgraph(graph: DependencyGraph, keep: set[str]) -> DependencyGraph: + repos = tuple(n for n in graph.repos if n.name in keep) + edges = tuple(e for e in graph.edges + if e.from_repo in keep and (e.external or e.to_repo in keep)) + roles = {k: v for k, v in graph.roles.items() if k in keep} + return DependencyGraph(repos, edges, roles, graph.warnings) diff --git a/client-plugin/server/src/index_graph/context/select.py b/client-plugin/server/src/index_graph/context/select.py new file mode 100644 index 0000000..686896e --- /dev/null +++ b/client-plugin/server/src/index_graph/context/select.py @@ -0,0 +1,251 @@ +"""Path selection with typed rejection receipts (index.path-selection/v1). + +Every path the selector considers lands in exactly one of two buckets: +selected, or rejected with a typed receipt naming a reason code from a +closed set and the rule that dropped it. Counts must reconcile +(candidates == selected + rejected), and ``reconcile_selection`` +re-derives that ledger from the lists themselves, so a silent drop, a +forged count, or an untyped rejection is machine-detectable. A selector +that cannot show what it dropped is not auditable. +""" +from __future__ import annotations + +import copy +import json +import os +from collections import Counter +from pathlib import Path + +from ..graph.walk import EXCLUDE_DIRS + +RECEIPT_SCHEMA = "index.path-selection/v1" +RESULT_SCHEMA = "index.path-selection-result/v1" +RECONCILIATION_SCHEMA = "index.path-selection-reconciliation/v1" + +# The closed set of rejection reason codes. The validator refuses anything +# outside it; a new code is added here, never invented at a call site. +REASON_CODES = frozenset({ + "excluded-by-rule", # a directory pruned by the shared EXCLUDE_DIRS rule + "suffix-mismatch", # a file whose suffix is outside the requested set + "over-budget", # a file beyond the max_files budget + "not-found", # the selection root does not exist + "unreadable", # a selected file that failed the read probe +}) + +_RECEIPT_FIELDS = ("schema", "path", "reason_code", "rule_ref") +_EXCLUDE_RULE_REF = "index_graph.graph.walk.EXCLUDE_DIRS" + + +def _receipt(path: str, reason_code: str, rule_ref: str) -> dict: + return {"schema": RECEIPT_SCHEMA, "path": path, + "reason_code": reason_code, "rule_ref": rule_ref} + + +def _result(rules: dict, selected: list[str], rejected: list[dict]) -> dict: + return { + "schema": RESULT_SCHEMA, + "rules": rules, + "selected": selected, + "rejected": rejected, + "counts": {"candidates": len(selected) + len(rejected), + "selected": len(selected), "rejected": len(rejected)}, + } + + +def _walk_candidates(root: Path): + """Yield (relative_posix_path, pruned) for every candidate under root. + + A pruned entry is a directory dropped by EXCLUDE_DIRS. It counts as one + candidate and carries one receipt, and nothing beneath it is walked, so + a buried file can never bypass the directory's receipt. + """ + for dirpath, dirnames, filenames in os.walk(root, onerror=lambda _e: None): + base = Path(dirpath) + for name in sorted(d for d in dirnames if d in EXCLUDE_DIRS): + yield (base / name).relative_to(root).as_posix(), True + dirnames[:] = sorted(d for d in dirnames if d not in EXCLUDE_DIRS) + for name in sorted(filenames): + yield (base / name).relative_to(root).as_posix(), False + + +def select_paths(root: Path | str, suffixes: tuple[str, ...] | None = None, + max_files: int | None = None) -> dict: + """Split every candidate under ``root`` into selected or rejected. + + ``suffixes`` keeps only files ending in one of the given suffixes + (None keeps every file). ``max_files`` is a budget: files beyond it + are rejected with over-budget receipts, never silently dropped. + """ + if max_files is not None and max_files < 0: + raise ValueError("max_files must be >= 0") + root = Path(root) + rules = {"suffixes": list(suffixes) if suffixes else None, + "max_files": max_files} + if not root.is_dir(): + return _result(rules, [], [_receipt(".", "not-found", "select.root")]) + selected: list[str] = [] + rejected: list[dict] = [] + for rel, pruned in _walk_candidates(root): + if pruned: + rejected.append(_receipt(rel, "excluded-by-rule", _EXCLUDE_RULE_REF)) + elif suffixes is not None and not rel.endswith(tuple(suffixes)): + rejected.append(_receipt(rel, "suffix-mismatch", + "select.suffixes:" + ",".join(suffixes))) + else: + selected.append(rel) + selected.sort() + if max_files is not None and len(selected) > max_files: + for rel in selected[max_files:]: + rejected.append(_receipt(rel, "over-budget", + f"select.max_files:{max_files}")) + selected = selected[:max_files] + rejected.sort(key=lambda receipt: receipt["path"]) + return _result(rules, selected, rejected) + + +def validate_receipt(receipt: object) -> list[str]: + """Return every reason ``receipt`` is not a valid rejection receipt. + + An empty list means valid. The check is closed-world: the exact schema + id, exactly the four typed fields, and a reason code from REASON_CODES. + """ + if not isinstance(receipt, dict): + return [f"receipt must be an object, got {type(receipt).__name__}"] + errors: list[str] = [] + unknown = sorted(set(receipt) - set(_RECEIPT_FIELDS)) + if unknown: + errors.append("unknown fields: " + ", ".join(unknown)) + for field in _RECEIPT_FIELDS: + if field not in receipt: + errors.append(f"missing field: {field}") + elif not isinstance(receipt[field], str) or not receipt[field]: + errors.append(f"{field} must be a non-empty string") + schema = receipt.get("schema") + if isinstance(schema, str) and schema and schema != RECEIPT_SCHEMA: + errors.append(f"schema must be {RECEIPT_SCHEMA}, got {schema!r}") + code = receipt.get("reason_code") + if isinstance(code, str) and code and code not in REASON_CODES: + errors.append(f"unknown reason_code: {code!r} " + f"(closed set: {', '.join(sorted(REASON_CODES))})") + return errors + + +def reconcile_selection(selection: object) -> dict: + """Re-derive the selection ledger from its own lists; DRIFT on any gap. + + MATCH is earned, never declared: the declared counts must equal the + list lengths, candidates must equal selected + rejected, every receipt + must validate against the closed reason-code set, and no path may be + booked twice across selected and rejected. + """ + if not isinstance(selection, dict) or selection.get("schema") != RESULT_SCHEMA: + return _report([{"code": "result-schema", + "detail": f"selection schema must be {RESULT_SCHEMA}"}]) + failures: list[dict] = [] + selected = selection.get("selected") or [] + rejected = selection.get("rejected") or [] + counts = selection.get("counts") or {} + if counts.get("candidates") != counts.get("selected", 0) + counts.get("rejected", 0): + failures.append({"code": "counts-mismatch", + "detail": (f"declared candidates={counts.get('candidates')} != " + f"selected={counts.get('selected')} + " + f"rejected={counts.get('rejected')}")}) + if counts.get("selected") != len(selected): + failures.append({"code": "selected-count-mismatch", + "detail": (f"declared selected={counts.get('selected')} " + f"but the list holds {len(selected)}")}) + if counts.get("rejected") != len(rejected): + failures.append({"code": "rejected-count-mismatch", + "detail": (f"declared rejected={counts.get('rejected')} but the " + f"list holds {len(rejected)} receipt(s); a rejection " + "without a receipt is a silent drop")}) + for receipt in rejected: + errors = validate_receipt(receipt) + if errors: + path = receipt.get("path", "?") if isinstance(receipt, dict) else "?" + failures.append({"code": "invalid-receipt", + "detail": f"{path}: " + "; ".join(errors)}) + booked = Counter(list(selected) + + [r.get("path") for r in rejected if isinstance(r, dict)]) + for path, times in sorted(booked.items(), key=lambda item: str(item[0])): + if times > 1 and path is not None: + failures.append({"code": "duplicate-path", + "detail": f"path booked {times} times: {path}"}) + return _report(failures) + + +def _report(failures: list[dict]) -> dict: + return {"schema": RECONCILIATION_SCHEMA, + "verdict": "MATCH" if not failures else "DRIFT", + "failures": failures} + + +def reject_selected(selection: dict, path: str, reason_code: str, + rule_ref: str) -> dict: + """Move ``path`` from selected to rejected, leaving a typed receipt. + + Refuses reason codes outside the closed set and paths that are not + currently selected, so a caller cannot mint receipts for paths the + selection never held. Candidates are conserved. + """ + if reason_code not in REASON_CODES: + raise ValueError(f"unknown reason_code: {reason_code!r}") + if path not in selection["selected"]: + raise ValueError(f"path is not selected: {path!r}") + selection["selected"].remove(path) + receipt = _receipt(path, reason_code, rule_ref) + selection["rejected"].append(receipt) + selection["counts"]["selected"] -= 1 + selection["counts"]["rejected"] += 1 + return receipt + + +def _read_probe(path: Path) -> None: + """Raise OSError if ``path`` cannot actually be opened and read.""" + with path.open("rb") as handle: + handle.read(1) + + +def probe_readable(selection: dict, root: Path | str) -> dict: + """Return a copy of ``selection`` where files failing the read probe + have moved to rejected with unreadable receipts. Probing reclassifies; + it never drops, so the candidate count is conserved.""" + root = Path(root) + probed = copy.deepcopy(selection) + for rel in list(probed["selected"]): + try: + _read_probe(root / rel) + except OSError: + reject_selected(probed, rel, "unreadable", "select.read_check") + return probed + + +def run_select(root: Path | str, suffixes: tuple[str, ...] | None, + max_files: int | None) -> dict: + """Select, probe readability, reconcile: the shared CLI/MCP payload.""" + selection = probe_readable( + select_paths(root, suffixes=suffixes, max_files=max_files), root) + return {"selection": selection, + "reconciliation": reconcile_selection(selection)} + + +def cmd_select(args) -> int: + """CLI face: print the selection and its reconciliation report.""" + if args.max_files is not None and args.max_files < 0: + raise SystemExit("--max-files must be >= 0") + suffixes = tuple(args.suffixes) if args.suffixes else None + payload = run_select(args.root.resolve(), suffixes, args.max_files) + selection, report = payload["selection"], payload["reconciliation"] + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + counts = selection["counts"] + print(f"select verdict={report['verdict']} " + f"candidates={counts['candidates']} " + f"selected={counts['selected']} rejected={counts['rejected']}") + reasons = Counter(r["reason_code"] for r in selection["rejected"]) + for code, count in sorted(reasons.items()): + print(f" {code}: {count}") + for failure in report["failures"]: + print(f" [{failure['code']}] {failure['detail']}") + return 0 if report["verdict"] == "MATCH" else 1 diff --git a/client-plugin/server/src/index_graph/drift/__init__.py b/client-plugin/server/src/index_graph/drift/__init__.py new file mode 100644 index 0000000..d7761b1 --- /dev/null +++ b/client-plugin/server/src/index_graph/drift/__init__.py @@ -0,0 +1,7 @@ +"""Snapshots over time and the drift between them.""" +from __future__ import annotations + +from .snapshot import snapshot_pack, dumps_canonical, load_snapshot +from .diff import DriftReport, diff_snapshots + +__all__ = ["snapshot_pack", "dumps_canonical", "load_snapshot", "DriftReport", "diff_snapshots"] diff --git a/client-plugin/server/src/index_graph/drift/diff.py b/client-plugin/server/src/index_graph/drift/diff.py new file mode 100644 index 0000000..3458863 --- /dev/null +++ b/client-plugin/server/src/index_graph/drift/diff.py @@ -0,0 +1,65 @@ +"""Diff two snapshots into a DriftReport with a MATCH/DRIFT verdict.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DriftReport: + repos_added: tuple[str, ...] + repos_removed: tuple[str, ...] + edges_added: tuple[str, ...] + edges_removed: tuple[str, ...] + cycles_introduced: tuple[tuple[str, ...], ...] + cycles_cleared: tuple[tuple[str, ...], ...] + roles_changed: tuple[tuple[str, str, str], ...] + + @property + def verdict(self) -> str: + changed = any([ + self.repos_added, self.repos_removed, self.edges_added, + self.edges_removed, self.cycles_introduced, self.cycles_cleared, + self.roles_changed, + ]) + return "DRIFT" if changed else "MATCH" + + def to_json(self) -> dict: + return { + "verdict": self.verdict, + "repos_added": list(self.repos_added), + "repos_removed": list(self.repos_removed), + "edges_added": list(self.edges_added), + "edges_removed": list(self.edges_removed), + "cycles_introduced": [list(c) for c in self.cycles_introduced], + "cycles_cleared": [list(c) for c in self.cycles_cleared], + "roles_changed": [list(t) for t in self.roles_changed], + } + + +def _cycle_set(snap: dict) -> set[tuple[str, ...]]: + # a cycle is an unordered node set; normalize so diffs are order-insensitive + return {tuple(sorted(c)) for c in snap.get("cycles", [])} + + +def diff_snapshots(old: dict, new: dict) -> DriftReport: + for snap in (old, new): + if not isinstance(snap, dict) or snap.get("schema") != "index.snapshot/1": + raise ValueError("not an index.snapshot/1 document; refusing to diff") + o_repos, n_repos = set(old.get("repos", [])), set(new.get("repos", [])) + o_edges, n_edges = set(old.get("edges", [])), set(new.get("edges", [])) + o_cyc, n_cyc = _cycle_set(old), _cycle_set(new) + o_roles, n_roles = old.get("roles", {}), new.get("roles", {}) + roles_changed: list[tuple[str, str, str]] = [] + for repo in sorted(set(o_roles) & set(n_roles)): + a, b = o_roles.get(repo, []), n_roles.get(repo, []) + if a != b: + roles_changed.append((repo, ",".join(a), ",".join(b))) + return DriftReport( + repos_added=tuple(sorted(n_repos - o_repos)), + repos_removed=tuple(sorted(o_repos - n_repos)), + edges_added=tuple(sorted(n_edges - o_edges)), + edges_removed=tuple(sorted(o_edges - n_edges)), + cycles_introduced=tuple(sorted(n_cyc - o_cyc)), + cycles_cleared=tuple(sorted(o_cyc - n_cyc)), + roles_changed=tuple(roles_changed), + ) diff --git a/client-plugin/server/src/index_graph/drift/snapshot.py b/client-plugin/server/src/index_graph/drift/snapshot.py new file mode 100644 index 0000000..d685af8 --- /dev/null +++ b/client-plugin/server/src/index_graph/drift/snapshot.py @@ -0,0 +1,30 @@ +"""Canonical, minimal, sorted projection of a context pack for drift diffing.""" +from __future__ import annotations + +import json + + +def snapshot_pack(pack: dict) -> dict: + edges = sorted( + f"{r['from']} -> {r['to']}" + for r in pack.get("relations", []) + if not r.get("external") and r.get("to") + ) + roles = {k: list(v) for k, v in sorted(pack.get("roles", {}).items())} + cycles = sorted(tuple(sorted(c)) for c in pack.get("cycles", [])) + repos = sorted(roles.keys()) + return { + "schema": "index.snapshot/1", + "repos": repos, + "edges": edges, + "roles": roles, + "cycles": [list(c) for c in cycles], + } + + +def dumps_canonical(obj: dict) -> str: + return json.dumps(obj, sort_keys=True, separators=(",", ":")) + + +def load_snapshot(text: str) -> dict: + return json.loads(text) diff --git a/client-plugin/server/src/index_graph/flagship.py b/client-plugin/server/src/index_graph/flagship.py new file mode 100644 index 0000000..99712a4 --- /dev/null +++ b/client-plugin/server/src/index_graph/flagship.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import json +import tempfile +import time +from pathlib import Path +from typing import Any + +from index_graph import __version__ + +SCHEMA = "project-telos.flagship-action/v1" +TOOL = "index" +TELOS_CONTRACTS = { + "host_surfaces": ["CLI JSON", "MCP stdio", "plugins", "IDEs", "TUIs", "apps"], + "schemas": [ + "project-telos.flagship-action/v1", + "project-telos.context-envelope/v1", + "project-telos.action-receipt/v1", + ], + "workflow_domains": ["enterprise", "research", "creative", "scientific", "education"], + "second_brain_role": ( + "map codebases, assets, docs, and dormant engine parts into compact reusable context " + "with selection summaries, freshness roots, and source-ref expansion handles" + ), + "privacy_boundary": "hosts receive receipts, hashes, redacted refs, and verdicts; raw private payloads stay in local adapters", +} + + +def envelope(command: str, *, status: str = "MATCH", native: dict | None = None, + next_actions: list[dict] | None = None, + diagnostics: list[dict] | None = None) -> dict: + return { + "schema": SCHEMA, + "tool": TOOL, + "tool_version": __version__, + "command": command, + "status": status, + "inputs": [], + "outputs": [], + "receipts": [], + "native": native or {}, + "next_actions": next_actions or [], + "diagnostics": diagnostics or [], + } + + +def _next(tool: str, action: str, reason: str) -> dict: + return {"tool": tool, "action": action, "reason": reason, "inputs": [], "priority": "normal"} + + +def _mcp_map_probe() -> dict[str, Any]: + start = time.perf_counter() + try: + with tempfile.TemporaryDirectory(prefix="index-doctor-") as tmp: + root = Path(tmp) + repo = root / "solo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "README.md").write_text("# Solo\n", encoding="utf-8") + + from .mcp import call_tool + + payload = json.loads(call_tool("index.map", {"root": str(root)})) + except Exception as exc: + return { + "name": "mcp_map_probe", + "status": "DRIFT", + "error": type(exc).__name__, + "elapsed_ms": round((time.perf_counter() - start) * 1000), + "side_effect": "temporary_workspace", + } + + status = ( + "MATCH" + if payload.get("repo_count") == 1 + and payload.get("absolute_paths_included") is False + else "DRIFT" + ) + return { + "name": "mcp_map_probe", + "status": status, + "repo_count": payload.get("repo_count"), + "metadata_status": payload.get("metadata_status"), + "metadata_unknown_count": payload.get("metadata_unknown_count"), + "absolute_paths_included": payload.get("absolute_paths_included"), + "elapsed_ms": round((time.perf_counter() - start) * 1000), + "side_effect": "temporary_workspace", + } + + +def status_payload() -> dict: + from .mcp import _tool_defs + + return envelope( + "status", + native={ + "role": "structure-context", + "commands": ["map", "graph", "context", "context-envelope", "route", "select", + "atlas", "wiki", "serve", "verify", "invalidate", "router", "router-job"], + "operator_commands": ["status", "doctor", "demo", "mcp"], + "mcp_tools": [tool["name"] for tool in _tool_defs()], + "current_status": ( + f"{__version__} workspace atlas, certificates, freshness, benchmarking, " + "selection-aware context envelopes, and MCP parity" + ), + "telos_contracts": TELOS_CONTRACTS, + }, + next_actions=[_next("gather", "docs", "gather docs backing structural decisions")], + ) + + +def doctor_payload() -> dict: + checks: list[dict[str, Any]] = [ + {"name": "workspace_map", "status": "MATCH"}, + {"name": "context_pack", "status": "MATCH"}, + {"name": "structural_verification", "status": "MATCH"}, + _mcp_map_probe(), + ] + status = "MATCH" if all(check["status"] == "MATCH" for check in checks) else "DRIFT" + diagnostics = [] if status == "MATCH" else [{ + "code": "mcp_map_probe_drift", + "message": "MCP map probe failed on a bounded temporary workspace", + }] + return envelope( + "doctor", + status=status, + native={"checks": checks, "filesystem_writes_performed": True}, + next_actions=[_next("forum", "route", "route the next workspace action")], + diagnostics=diagnostics, + ) + + +def demo_payload() -> dict: + return envelope( + "demo", + native={"command": "index map --root --json"}, + next_actions=[_next("telos", "workflow", "render workspace structure into the shared room")], + ) + + +def emit(payload: dict, as_json: bool) -> int: + if as_json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(f"status={payload['status']} tool={payload['tool']} command={payload['command']}") + for action in payload["next_actions"]: + print(f"next: {action['tool']} {action['action']} - {action['reason']}") + return 0 + + +def cmd_status(args) -> int: + return emit(status_payload(), args.json) + + +def cmd_doctor(args) -> int: + return emit(doctor_payload(), args.json) + + +def cmd_demo(args) -> int: + return emit(demo_payload(), args.json) diff --git a/client-plugin/server/src/index_graph/freshness/__init__.py b/client-plugin/server/src/index_graph/freshness/__init__.py new file mode 100644 index 0000000..3142034 --- /dev/null +++ b/client-plugin/server/src/index_graph/freshness/__init__.py @@ -0,0 +1,8 @@ +"""Content fingerprints and the freshness comparison (the 'has ground truth moved?' check).""" +from .compare import REPORT_SCHEMA, compare_freshness +from .fingerprint import SCHEMA, repo_fingerprint, workspace_fingerprint + +__all__ = [ + "SCHEMA", "REPORT_SCHEMA", + "repo_fingerprint", "workspace_fingerprint", "compare_freshness", +] diff --git a/client-plugin/server/src/index_graph/freshness/compare.py b/client-plugin/server/src/index_graph/freshness/compare.py new file mode 100644 index 0000000..1b57a5a --- /dev/null +++ b/client-plugin/server/src/index_graph/freshness/compare.py @@ -0,0 +1,33 @@ +"""Compare a recorded freshness stamp against the live workspace fingerprint.""" +from __future__ import annotations + +from .fingerprint import SCHEMA + +REPORT_SCHEMA = "index.freshness-report/1" + + +def compare_freshness(stamp: dict, current: dict) -> dict: + """FRESH if every repo fingerprint matches; STALE with named deltas otherwise. + + Pure (no I/O), so the report is re-checkable: a consumer recomputes the + current fingerprint and runs this again. Raises ValueError if either side + is not an index.freshness/1 document. + """ + for doc, who in ((stamp, "stamp"), (current, "current")): + if not isinstance(doc, dict) or doc.get("schema") != SCHEMA: + raise ValueError(f"{who} is not an {SCHEMA} document") + s_repos = stamp.get("repos", {}) + c_repos = current.get("repos", {}) + added = sorted(set(c_repos) - set(s_repos)) + removed = sorted(set(s_repos) - set(c_repos)) + changed = sorted(n for n in (set(s_repos) & set(c_repos)) if s_repos[n] != c_repos[n]) + fresh = not (added or removed or changed) + return { + "schema": REPORT_SCHEMA, + "verdict": "FRESH" if fresh else "STALE", + "stamp_root": stamp.get("root"), + "current_root": current.get("root"), + "repos_added": added, + "repos_removed": removed, + "repos_changed": changed, + } diff --git a/client-plugin/server/src/index_graph/freshness/fingerprint.py b/client-plugin/server/src/index_graph/freshness/fingerprint.py new file mode 100644 index 0000000..019bef5 --- /dev/null +++ b/client-plugin/server/src/index_graph/freshness/fingerprint.py @@ -0,0 +1,131 @@ +"""Deterministic content fingerprints: detect when graph-relevant content changed. + +A repo fingerprint is a SHA-256 over the sorted relative path and content SHA-256 +for every file a resolver could read (manifests and sources across all nine +ecosystems), so identical content yields an identical fingerprint on any machine, +and any graph-relevant edit, addition, or removal changes it. Working bytes are +always read: Git index flags and clean filters can hide resolver-visible edits. +The workspace fingerprint folds the per-repo fingerprints under their names. + +It is conservative on purpose. It may report a change to a file that does not +alter the resolved graph (so STALE can be a false alarm), but it never misses +a change to a file that could (so FRESH is never a false assurance). The set +of relevant files is declared by the resolvers themselves +(`fingerprint_names`, `fingerprint_suffixes`, `fingerprint_globs`), so a new +resolver is covered without touching this module. +""" +from __future__ import annotations + +import hashlib +import fnmatch +from collections.abc import Iterator +from pathlib import Path +from time import perf_counter + +from ..graph.resolvers import ALL_RESOLVERS +from ..graph.walk import read_source_bytes, walk_files + +SCHEMA = "index.freshness/1" + + +def _matchers(resolvers): + names: set[str] = set() + suffixes: set[str] = set() + globs: set[str] = set() + for r in resolvers: + names |= set(getattr(r, "fingerprint_names", ())) + suffixes |= set(getattr(r, "fingerprint_suffixes", ())) + globs |= set(getattr(r, "fingerprint_globs", ())) + return names, tuple(sorted(suffixes)), tuple(sorted(globs)) + + +def _is_relevant(filename: str, names, suffixes, globs) -> bool: + if filename in names: + return True + if suffixes and filename.endswith(suffixes): + return True + # fnmatchcase, not fnmatch: fnmatch lowercases via os.path.normcase on + # Windows, which would make the fingerprint platform-dependent. + return any(fnmatch.fnmatchcase(filename, g) for g in globs) + + +def _add_stat(stats: dict[str, int] | None, key: str, value: int) -> None: + if stats is not None: + stats[key] = int(stats.get(key, 0)) + int(value) + + +def _add_timing(stats: dict[str, int] | None, key: str, started: float) -> None: + _add_stat(stats, key, int((perf_counter() - started) * 1000)) + + +def relevant_files(repo_root: Path, resolvers=ALL_RESOLVERS, *, checkpoint=None, + stop_at_nested_repos: bool = False) -> Iterator[Path]: + """Yield every graph-relevant file under repo_root (the manifests and source + suffixes the resolvers read, across all ecosystems), pruning EXCLUDE_DIRS. + Fail-closed: a missing or unreadable tree yields nothing rather than raising. + When `stop_at_nested_repos` is true, child repositories are excluded so a + parent repo fingerprint matches the graph builder's repository boundary. + """ + names, suffixes, globs = _matchers(resolvers) + yield from walk_files( + Path(repo_root), + names=tuple(sorted(names)) or None, + suffixes=suffixes or None, + globs=globs or None, + checkpoint=checkpoint, + stop_at_nested_repos=stop_at_nested_repos, + ) + + +def repo_fingerprint(repo_root: Path, resolvers=ALL_RESOLVERS, *, stats: dict[str, int] | None = None) -> str: + """A SHA-256 over the sorted (relpath, file-sha256) of every relevant file. + + Fail-closed: an unreadable file contributes a fixed marker rather than + raising, and a missing or unreadable tree yields the empty-set hash. + """ + root = Path(repo_root) + entries = [] + started = perf_counter() + files = list(relevant_files(root, resolvers, stop_at_nested_repos=True)) + _add_timing(stats, "fingerprint_walk_ms", started) + _add_stat(stats, "fingerprint_files", len(files)) + started = perf_counter() + byte_count = 0 + unreadable = 0 + for p in files: + try: + data = read_source_bytes(p) + byte_count += len(data) + digest = hashlib.sha256(data).hexdigest() + except OSError: + unreadable += 1 + digest = "unreadable" + try: + rel = p.relative_to(root).as_posix() + except ValueError: + rel = p.as_posix() + entries.append((rel, digest)) + _add_stat(stats, "fingerprint_bytes", byte_count) + _add_stat(stats, "fingerprint_unreadable", unreadable) + _add_timing(stats, "fingerprint_read_hash_ms", started) + entries.sort() + h = hashlib.sha256() + for rel, digest in entries: + h.update(rel.encode("utf-8")) + h.update(b"\0") + h.update(digest.encode("ascii")) + h.update(b"\n") + return h.hexdigest() + + +def workspace_fingerprint(repo_paths: dict[str, Path], resolvers=ALL_RESOLVERS) -> dict: + """An index.freshness/1 stamp: a per-repo fingerprint map plus a root fold.""" + repos = {name: repo_fingerprint(root, resolvers) + for name, root in sorted(repo_paths.items())} + h = hashlib.sha256() + for name in sorted(repos): + h.update(name.encode("utf-8")) + h.update(b"\0") + h.update(repos[name].encode("ascii")) + h.update(b"\n") + return {"schema": SCHEMA, "root": h.hexdigest(), "repos": repos} diff --git a/client-plugin/server/src/index_graph/freshness/invalidate.py b/client-plugin/server/src/index_graph/freshness/invalidate.py new file mode 100644 index 0000000..8c321af --- /dev/null +++ b/client-plugin/server/src/index_graph/freshness/invalidate.py @@ -0,0 +1,273 @@ +"""Typed invalidation reports: the diff that names what it invalidates. + +`index freshness` says THAT the workspace moved; `index.invalidation/1` +says WHAT that movement invalidates. A pin (`index.invalidation-pin/1`) +records per-file hashes plus the structural snapshot at mint time. +Comparing it against the current tree lands every fingerprinted artifact +or scope in exactly one of two buckets: invalidated, with a reason code +from a closed set, or still valid. Counts must reconcile (invalidated + +still_valid == fingerprinted scope), and `reconcile_invalidation` +re-derives that ledger from the report itself, so a forged count, an +unknown reason code, or a double-booked scope is machine-detectable. +""" +from __future__ import annotations + +import hashlib +from pathlib import Path + +from ..certify import canonical_sha +from .fingerprint import relevant_files + +PIN_SCHEMA = "index.invalidation-pin/1" +REPORT_SCHEMA = "index.invalidation/1" +RECONCILIATION_SCHEMA = "index.invalidation-reconciliation/1" + +# The closed set of invalidation reason codes. A new code is added here, +# never invented at a call site. +REASON_CODES = frozenset({ + "file-changed", # a pinned graph-relevant file's content moved + "file-removed", # a pinned graph-relevant file is gone + "dependency-edge-changed", # the structural snapshot (edges/roles/cycles) moved + "doc-changed", # a pinned doc the context pack reads moved + "unversioned", # content now in scope that the pin never versioned +}) + +# The derived artifacts index fingerprints, invalidated as workspace-level scopes. +ARTIFACTS = ("certificate", "context-pack", "graph-snapshot") + +# The docs the context pack reads (repo descriptions come from these). +DOC_NAMES = ("README.md", "README.rst", "README.txt", "readme.md") + +EVIDENCE_LIMIT = 5 + +# Most severe first: aggregation picks the first code its inputs contain. +_REASON_PRIORITY = ("file-removed", "file-changed", "dependency-edge-changed", + "doc-changed", "unversioned") + + +def _hash_file(path: Path) -> str: + try: + return hashlib.sha256(path.read_bytes()).hexdigest() + except OSError: + return "unreadable" + + +def _repo_state(repo_root: Path) -> dict: + """Per-file hashes for one repo: the graph-relevant files the resolvers + read, and the root docs the context pack reads. Fail-closed: a missing + tree yields empty maps rather than raising.""" + root = Path(repo_root) + graph_files: dict[str, str] = {} + for p in relevant_files(root): + try: + rel = p.relative_to(root).as_posix() + except ValueError: + rel = p.as_posix() + graph_files[rel] = _hash_file(p) + try: + entries = {p.name for p in root.iterdir() if p.is_file()} + except OSError: + entries = set() + doc_files = {name: _hash_file(root / name) for name in DOC_NAMES if name in entries} + return {"graph_files": graph_files, "doc_files": doc_files} + + +def _pin_state(repo_paths: dict[str, Path], snapshot: dict) -> dict: + return {"repos": {name: _repo_state(path) + for name, path in sorted(repo_paths.items())}, + "snapshot": snapshot} + + +def _workspace_inputs(root: Path) -> tuple[dict[str, Path], dict]: + from ..config import load_config + from ..context.pack import to_json + from ..drift import snapshot_pack + from ..graph.build import build_graph + from ..scan import discover_repos, repo_key_map + config = load_config(None, Path(root)) + repo_paths = repo_key_map( + Path(root), + discover_repos(Path(root), config), + include_root_repo=config.include_root_repo, + ) + return repo_paths, snapshot_pack(to_json(build_graph(repo_paths))) + + +def mint_pin(root: Path | str) -> dict: + """Pin the current tree: per-file hashes plus the structural snapshot, + content-addressed by ``pinned_ref`` (the canonical SHA-256 of the state, + per docs/PROTOCOL.md hashing).""" + from .. import __version__ + state = _pin_state(*_workspace_inputs(Path(root))) + return {"schema": PIN_SCHEMA, "tool_version": __version__, + "pinned_ref": canonical_sha(state), **state} + + +def _split(pinned: dict, current: dict) -> tuple[list[str], list[str], list[str]]: + """(removed, changed, added) keys between two {path: sha} maps.""" + removed = sorted(set(pinned) - set(current)) + added = sorted(set(current) - set(pinned)) + changed = sorted(k for k in set(pinned) & set(current) if pinned[k] != current[k]) + return removed, changed, added + + +def _repo_reason(pinned: dict, current: dict) -> tuple[str | None, list[str]]: + """The single most severe reason a pinned repo scope moved, with evidence.""" + removed, changed, added = _split(pinned.get("graph_files", {}), + current.get("graph_files", {})) + doc_removed, doc_changed, doc_added = _split(pinned.get("doc_files", {}), + current.get("doc_files", {})) + if removed: + return "file-removed", removed[:EVIDENCE_LIMIT] + if changed: + return "file-changed", changed[:EVIDENCE_LIMIT] + if added: + return "unversioned", added[:EVIDENCE_LIMIT] + docs = sorted(set(doc_removed) | set(doc_changed) | set(doc_added)) + if docs: + return "doc-changed", docs[:EVIDENCE_LIMIT] + return None, [] + + +def _snapshot_reason(pinned: dict, current: dict) -> tuple[str | None, list[str]]: + """Why the structural snapshot no longer holds, or None when it does.""" + old_edges, new_edges = set(pinned.get("edges", [])), set(current.get("edges", [])) + if old_edges != new_edges: + diff = sorted(f"- {e}" for e in old_edges - new_edges) + diff += sorted(f"+ {e}" for e in new_edges - old_edges) + return "dependency-edge-changed", diff[:EVIDENCE_LIMIT] + old_repos, new_repos = set(pinned.get("repos", [])), set(current.get("repos", [])) + if old_repos - new_repos: + return "file-removed", sorted(old_repos - new_repos)[:EVIDENCE_LIMIT] + if new_repos - old_repos: + return "unversioned", sorted(new_repos - old_repos)[:EVIDENCE_LIMIT] + if pinned != current: # roles or cycles moved with identical edge strings + return "dependency-edge-changed", [] + return None, [] + + +def _aggregate(reasons: dict[str, list[str]]) -> tuple[str, list[str]]: + """Fold contributing reasons into the single most severe one.""" + for code in _REASON_PRIORITY: + if code in reasons: + return code, list(dict.fromkeys(reasons[code]))[:EVIDENCE_LIMIT] + raise ValueError("aggregate called with no reasons") # pragma: no cover + + +def _item(scope: str, reason: str, evidence: list[str]) -> dict: + return {"artifact_or_scope": scope, "reason_code": reason, "evidence": evidence} + + +def invalidation_report(pin: dict, root: Path | str, *, recheck: str | None = None) -> dict: + """Diff the pinned state against the current tree and name what it invalidates. + + A tampered pin hash simply reads as a moved file (STALE, file-changed); + only a document that is not a pin at all raises ValueError. + """ + if (not isinstance(pin, dict) or pin.get("schema") != PIN_SCHEMA + or not isinstance(pin.get("repos"), dict) + or not isinstance(pin.get("snapshot"), dict)): + raise ValueError(f"pin is not an {PIN_SCHEMA} document") + repo_paths, current_snapshot = _workspace_inputs(Path(root)) + current = _pin_state(repo_paths, current_snapshot) + invalidated: list[dict] = [] + derived: dict[str, list[str]] = {} # reasons feeding certificate + context-pack + for name in sorted(pin["repos"]): + reason, evidence = _repo_reason(pin["repos"][name], + current["repos"].get(name, {})) + if reason: + invalidated.append(_item(f"repo:{name}", reason, evidence)) + derived.setdefault(reason, []).extend(evidence or [f"repo:{name}"]) + snap_reason, snap_evidence = _snapshot_reason(pin["snapshot"], current_snapshot) + if snap_reason: + invalidated.append(_item("graph-snapshot", snap_reason, snap_evidence)) + derived.setdefault(snap_reason, []).extend(snap_evidence or ["graph-snapshot"]) + new_repos = sorted(set(current["repos"]) - set(pin["repos"])) + if new_repos: + derived.setdefault("unversioned", []).extend(new_repos) + if derived: + reason, evidence = _aggregate(derived) + invalidated.append(_item("certificate", reason, evidence)) + invalidated.append(_item("context-pack", reason, evidence)) + invalidated.sort(key=lambda item: item["artifact_or_scope"]) + scope = sorted(list(ARTIFACTS) + [f"repo:{name}" for name in pin["repos"]]) + named = {item["artifact_or_scope"] for item in invalidated} + still_valid = [entry for entry in scope if entry not in named] + return { + "schema": REPORT_SCHEMA, + "pinned_ref": pin.get("pinned_ref"), + "current_ref": canonical_sha(current), + "verdict": "FRESH" if not invalidated else "STALE", + "invalidated": invalidated, + "still_valid": still_valid, + "counts": {"scope": len(scope), "invalidated": len(invalidated), + "still_valid": len(still_valid)}, + "recheck": recheck or "index invalidate --root ROOT --pin PIN --json", + } + + +def reconcile_invalidation(report: object) -> dict: + """Re-derive the invalidation ledger from the report itself; DRIFT on any gap. + + MATCH is earned, never declared: declared counts must equal the list + lengths, invalidated + still_valid must equal the fingerprinted scope, + every reason code must come from the closed set, no scope may be booked + twice, and the verdict must agree with the lists it sits over. + """ + if not isinstance(report, dict) or report.get("schema") != REPORT_SCHEMA: + return _reconciliation([{"code": "report-schema", + "detail": f"report schema must be {REPORT_SCHEMA}"}]) + invalidated = report.get("invalidated") or [] + still_valid = report.get("still_valid") or [] + failures = _count_failures(report.get("counts") or {}, invalidated, still_valid) + booked: dict[str, int] = {} + for item in invalidated: + scope_name = item.get("artifact_or_scope") if isinstance(item, dict) else None + code = item.get("reason_code") if isinstance(item, dict) else None + if code not in REASON_CODES: + failures.append({"code": "unknown-reason-code", + "detail": (f"{scope_name or '?'}: {code!r} (closed set: " + f"{', '.join(sorted(REASON_CODES))})")}) + if scope_name is not None: + booked[scope_name] = booked.get(scope_name, 0) + 1 + for scope_name in still_valid: + booked[scope_name] = booked.get(scope_name, 0) + 1 + for scope_name, times in sorted(booked.items(), key=lambda kv: str(kv[0])): + if times > 1: + failures.append({"code": "duplicate-scope", + "detail": f"scope booked {times} times: {scope_name}"}) + verdict = report.get("verdict") + if verdict == "UNVERIFIABLE": + if invalidated or still_valid: + failures.append({"code": "verdict-mismatch", + "detail": "an UNVERIFIABLE report must carry no scope entries"}) + elif verdict != ("FRESH" if not invalidated else "STALE"): + failures.append({"code": "verdict-mismatch", + "detail": (f"verdict {verdict!r} over " + f"{len(invalidated)} invalidation(s)")}) + return _reconciliation(failures) + + +def _count_failures(counts: dict, invalidated: list, still_valid: list) -> list[dict]: + """The declared-count checks: the ledger must add up before anything else.""" + failures: list[dict] = [] + if counts.get("scope") != counts.get("invalidated", 0) + counts.get("still_valid", 0): + failures.append({"code": "counts-mismatch", + "detail": (f"declared scope={counts.get('scope')} != " + f"invalidated={counts.get('invalidated')} + " + f"still_valid={counts.get('still_valid')}")}) + if counts.get("invalidated") != len(invalidated): + failures.append({"code": "invalidated-count-mismatch", + "detail": (f"declared invalidated={counts.get('invalidated')} " + f"but the list holds {len(invalidated)}")}) + if counts.get("still_valid") != len(still_valid): + failures.append({"code": "still-valid-count-mismatch", + "detail": (f"declared still_valid={counts.get('still_valid')} " + f"but the list holds {len(still_valid)}")}) + return failures + + +def _reconciliation(failures: list[dict]) -> dict: + return {"schema": RECONCILIATION_SCHEMA, + "verdict": "MATCH" if not failures else "DRIFT", + "failures": failures} diff --git a/client-plugin/server/src/index_graph/freshness/invalidate_cli.py b/client-plugin/server/src/index_graph/freshness/invalidate_cli.py new file mode 100644 index 0000000..c4e37a5 --- /dev/null +++ b/client-plugin/server/src/index_graph/freshness/invalidate_cli.py @@ -0,0 +1,80 @@ +"""CLI and shared-payload face for the typed invalidation report. + +`index invalidate --out PIN` mints a pin of the current tree; +`index invalidate --pin PIN` diffs it against the tree and emits the +`index.invalidation/1` report plus its reconciliation. The MCP tool +reuses `run_invalidate` and `mint_pin`, so the protocol face never +disagrees with the CLI. +""" +from __future__ import annotations + +import json +from pathlib import Path + +from .invalidate import ( + REPORT_SCHEMA, + invalidation_report, + mint_pin, + reconcile_invalidation, +) + +_EXIT = {"FRESH": 0, "STALE": 1, "UNVERIFIABLE": 2} + + +def _unverifiable(detail: str, recheck: str | None) -> dict: + return {"schema": REPORT_SCHEMA, "pinned_ref": None, "current_ref": None, + "verdict": "UNVERIFIABLE", "detail": detail, + "invalidated": [], "still_valid": [], + "counts": {"scope": 0, "invalidated": 0, "still_valid": 0}, + "recheck": recheck or "index invalidate --root ROOT --pin PIN --json"} + + +def run_invalidate(root: Path | str, pin: dict, *, recheck: str | None = None) -> dict: + """Report + reconciliation: the shared CLI/MCP payload. + + A document that is not a pin yields an UNVERIFIABLE report, not a crash; + a tampered pin yields STALE with file-changed reasons. + """ + try: + report = invalidation_report(pin, root, recheck=recheck) + except ValueError as exc: + report = _unverifiable(str(exc), recheck) + return {"report": report, "reconciliation": reconcile_invalidation(report)} + + +def cmd_invalidate(args) -> int: + """CLI face: mint a pin with --out, or report against one with --pin.""" + root = args.root.resolve() + if not root.is_dir(): + raise SystemExit(f"root not found: {root}") + if (args.out is None) == (args.pin is None): + raise SystemExit( + "invalidate: pass exactly one of --out PIN (mint) or --pin PIN (report)") + if args.out is not None: + pin = mint_pin(root) + args.out.write_text(json.dumps(pin, indent=2, sort_keys=True), encoding="utf-8") + print(f"wrote {args.out} repos={len(pin['repos'])} " + f"pinned_ref={pin['pinned_ref'][:12]}") + return 0 + try: + pin = json.loads(args.pin.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"invalidate: cannot read pin {args.pin}: {exc}") + recheck = f'index invalidate --root "{args.root}" --pin "{args.pin}" --json' + payload = run_invalidate(root, pin, recheck=recheck) + report = payload["report"] + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + counts = report["counts"] + line = (f"invalidation verdict={report['verdict']} scope={counts['scope']} " + f"invalidated={counts['invalidated']} " + f"still_valid={counts['still_valid']}") + if report.get("detail"): + line += f": {report['detail']}" + print(line) + for item in report["invalidated"]: + print(f" {item['artifact_or_scope']}: {item['reason_code']}") + for failure in payload["reconciliation"]["failures"]: + print(f" [{failure['code']}] {failure['detail']}") + return _EXIT[report["verdict"]] diff --git a/client-plugin/server/src/index_graph/freshness/watch.py b/client-plugin/server/src/index_graph/freshness/watch.py new file mode 100644 index 0000000..6adcc0b --- /dev/null +++ b/client-plugin/server/src/index_graph/freshness/watch.py @@ -0,0 +1,118 @@ +"""watch.py — incremental auto-resync: the live drift verdict competitors have, +plus the receipt they don't. + +Every codebase-map tool in the category auto-syncs on file change; index did +not, so its freshness was baseline-only (the workbench Health view says as much +honestly). This closes that gap by COMPOSING the freshness machinery rather +than adding new state: a watcher holds the prior workspace fingerprint, and on +each tick recomputes the current one and runs the pure `compare_freshness`. The +retained prior snapshot is exactly what turns "current fingerprint only" into a +real FRESH/STALE verdict — the thing a single --root pass cannot emit. + +Every resync emits a re-checkable sync receipt (schema below): the two +fingerprint roots, the verdict, and the named repo deltas. A consumer recomputes +the fingerprint and re-runs compare_freshness to confirm it — the watcher never +asks to be trusted. + +Zero dependencies: stdlib polling (mtime + the content fingerprint that already +exists), no OS file-event API, no watchdog package. Poll cadence is a floor on +latency, not a correctness property — the fingerprint is authoritative, so a +missed poll only delays a verdict, never fabricates one. + +Robustness: a rescan that raises (a file vanishing mid-walk, a permission flip) +is caught and reported as an errored tick, never crashing the loop; the prior +good fingerprint is retained so the next tick recovers. +""" +from __future__ import annotations + +import time +from collections.abc import Callable, Iterator +from pathlib import Path + +from .compare import compare_freshness +from .fingerprint import workspace_fingerprint + +SYNC_SCHEMA = "index.freshness-sync/1" + + +def _now(clock: Callable[[], float] | None) -> float: + return (clock or time.time)() + + +def sync_report(prev: dict, curr: dict, *, tick: int, at: float, + changed_paths: list[str] | None = None) -> dict: + """One re-checkable resync receipt. `prev`/`curr` are index.freshness/1 + fingerprints; the verdict is the pure compare over them.""" + cmp = compare_freshness(prev, curr) + return { + "schema": SYNC_SCHEMA, + "tick": tick, + "at": round(at, 3), + "verdict": cmp["verdict"], # FRESH | STALE + "prev_root": cmp["stamp_root"], + "curr_root": cmp["current_root"], + "repos_added": cmp["repos_added"], + "repos_removed": cmp["repos_removed"], + "repos_changed": cmp["repos_changed"], + "changed_paths": sorted(changed_paths) if changed_paths else [], + "recheck": "index freshness --root ROOT (recompute the fingerprint and re-compare)", + } + + +def diff_changed_paths(prev_paths: dict[str, str], curr_paths: dict[str, str]) -> list[str]: + """Which relevant files changed between two path->sha maps (for the receipt's + detail line). Optional; the verdict never depends on it.""" + out = [] + for p in set(prev_paths) | set(curr_paths): + if prev_paths.get(p) != curr_paths.get(p): + out.append(p) + return out + + +def watch_iter( + repo_paths: dict[str, Path], + *, + interval: float = 2.0, + max_ticks: int | None = None, + sleep: Callable[[float], None] | None = None, + clock: Callable[[], float] | None = None, + on_change: Callable[[dict], None] | None = None, +) -> Iterator[dict]: + """Yield a sync receipt for every tick on which the workspace fingerprint + changed (STALE), plus the initial baseline tick. Deterministic and testable: + inject `sleep`/`clock`; bound with `max_ticks`. `repo_paths` is re-read each + tick so newly added/removed repos are picked up by the caller-supplied map. + + The first yielded receipt is the baseline (tick 0, verdict FRESH vs itself). + Thereafter only ticks that DETECTED a change are yielded, so a consumer that + regenerates an artifact runs only when something actually moved.""" + sleep = sleep or time.sleep + prev = _safe_fingerprint(repo_paths) + baseline = sync_report(prev, prev, tick=0, at=_now(clock)) + if on_change: + on_change(baseline) + yield baseline + tick = 0 + while max_ticks is None or tick < max_ticks: + sleep(interval) + tick += 1 + curr = _safe_fingerprint(repo_paths) + if curr is None: # errored rescan: retain prev, report, continue + yield {"schema": SYNC_SCHEMA, "tick": tick, "at": round(_now(clock), 3), + "verdict": "UNVERIFIABLE", "error": "fingerprint rescan failed", + "prev_root": prev.get("root")} + continue + if curr.get("root") != prev.get("root"): + report = sync_report(prev, curr, tick=tick, at=_now(clock)) + prev = curr + if on_change: + on_change(report) + yield report + # FRESH ticks are silent by design (no yield): only movement is reported + + +def _safe_fingerprint(repo_paths: dict[str, Path]): + try: + return workspace_fingerprint(repo_paths) + except Exception: # never let a transient FS error kill the loop + return None diff --git a/client-plugin/server/src/index_graph/gitmeta.py b/client-plugin/server/src/index_graph/gitmeta.py new file mode 100644 index 0000000..258d513 --- /dev/null +++ b/client-plugin/server/src/index_graph/gitmeta.py @@ -0,0 +1,172 @@ +"""Git subprocess access and always-on credential redaction.""" + +from __future__ import annotations + +from dataclasses import dataclass +import re +import os +import subprocess +from pathlib import Path +from typing import Any + + +DEFAULT_GIT_TIMEOUT_SECONDS = 5.0 +STATUS_GLOBAL_ARGS = ["--no-optional-locks"] +STATUS_ARGS = ["status", "--porcelain=v2", "--branch", "--untracked-files=all"] + + +@dataclass(frozen=True) +class GitCommandResult: + ok: bool + stdout: str + error: str | None = None + + +class GitMetadataError(RuntimeError): + """Raised when repo cleanliness cannot be established safely.""" + + +def git_timeout_seconds() -> float: + raw = os.environ.get("INDEX_GIT_TIMEOUT_SECONDS") + if raw is None or raw == "": + return DEFAULT_GIT_TIMEOUT_SECONDS + try: + value = float(raw) + except ValueError: + return DEFAULT_GIT_TIMEOUT_SECONDS + return max(0.1, value) + + +def _git_env(repo: Path) -> dict[str, str]: + env = dict(os.environ) + ceiling = str(Path(repo).resolve().parent) + existing = env.get("GIT_CEILING_DIRECTORIES") + if existing: + parts = [part for part in existing.split(os.pathsep) if part] + if ceiling not in parts: + parts.append(ceiling) + env["GIT_CEILING_DIRECTORIES"] = os.pathsep.join(parts) + else: + env["GIT_CEILING_DIRECTORIES"] = ceiling + return env + +# A web remote carries its whole userinfo as a secret. The token often +# sits in the user slot with no password beside it, so the slot goes as a +# unit. +_USERINFO = re.compile(r"(?i)(https?://)[^/@]+@") +# Every other scheme keeps its username, because ssh://git@host names a +# user and nothing more. A colon means a password came with it, and that +# is a secret whatever the scheme says. +_PASSWORD_USERINFO = re.compile( + r"(?i)([a-z][a-z0-9+.-]*://)[^/@\s]*:[^/@\s]*@") +# Parameter names arrive prefixed as often as bare, so access_token has to +# match as readily as token. The value class runs to the end of the query +# on purpose: swallowing a trailing parameter redacts more than was asked +# for, and the alternative is publishing a second secret whose name this +# pattern does not know. +_SECRET_QUERY = re.compile( + r"(?i)(? str: + """Strip the parts of a remote URL a reader of the map must not get. + + What survives is the scheme, the host and the path, which is what + makes a remote recognisable. Every origin passes through here before + it reaches a row, so a map cannot carry a credential even when the + clone URL held one. + """ + clean = _USERINFO.sub(r"\1@", origin) + clean = _PASSWORD_USERINFO.sub(r"\1@", clean) + return _SECRET_QUERY.sub(r"\1=", clean) + + +def run_git_checked( + repo: Path, + args: list[str], + *, + global_args: list[str] | None = None, +) -> GitCommandResult: + try: + result = subprocess.run( + ["git", *(global_args or []), "-C", str(repo), *args], + text=True, capture_output=True, timeout=git_timeout_seconds(), check=False, + env=_git_env(repo), + ) + except subprocess.TimeoutExpired: + return GitCommandResult(False, "", "timeout") + if result.returncode != 0: + return GitCommandResult(False, "", f"exit-{result.returncode}") + return GitCommandResult(True, result.stdout.strip()) + + +def run_git(repo: Path, args: list[str]) -> str: + result = run_git_checked(repo, args) + return result.stdout if result.ok else "" + + +def _branch_from_status(lines: list[str]) -> str: + if not lines or not lines[0].startswith("## "): + return "" + branch = lines[0][3:].strip() + if branch.startswith("No commits yet on "): + return branch.removeprefix("No commits yet on ").strip() or "" + if "..." in branch: + branch = branch.split("...", 1)[0] + if branch.endswith("]") and "[" in branch: + branch = branch.rsplit("[", 1)[0].strip() + return branch or "" + + +def _metadata_from_porcelain_v2(status: str) -> tuple[str, str, int, int]: + branch = "" + head = "" + dirty = 0 + untracked = 0 + for line in status.splitlines(): + if line.startswith("# branch.head "): + branch = line.removeprefix("# branch.head ").strip() + if branch == "(detached)": + branch = "HEAD" + elif line.startswith("# branch.oid "): + oid = line.removeprefix("# branch.oid ").strip() + if oid and oid != "(initial)": + head = oid[:7] + elif line.startswith("? "): + untracked += 1 + elif line and not line.startswith("# "): + dirty += 1 + return branch, head, dirty, untracked + + +def _status_signature(status: str) -> str: + import hashlib + + return hashlib.sha256(status.encode("utf-8")).hexdigest() + + +def repo_metadata(repo: Path) -> dict[str, Any]: + status_result = run_git_checked(repo, STATUS_ARGS, global_args=STATUS_GLOBAL_ARGS) + if not status_result.ok: + raise GitMetadataError(f"git status unavailable: {status_result.error or 'unknown'}") + branch, head, dirty, untracked = _metadata_from_porcelain_v2(status_result.stdout) + status_lines = status_result.stdout.splitlines() + branch = ( + branch + or _branch_from_status(status_lines) + or run_git(repo, ["branch", "--show-current"]) + or run_git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]) + or "unknown" + ) + head = head or run_git(repo, ["rev-parse", "--short=7", "HEAD"]) or "unknown" + origin = sanitize_credentials(run_git(repo, ["config", "--get", "remote.origin.url"]) or "") + return { + "branch": branch, + "head": head, + "origin": origin, + "dirty_count": dirty, + "untracked_count": untracked, + "metadata_status": "ok", + "metadata_error": None, + "status_signature": _status_signature(status_result.stdout), + } diff --git a/client-plugin/server/src/index_graph/graph/__init__.py b/client-plugin/server/src/index_graph/graph/__init__.py new file mode 100644 index 0000000..e4baed2 --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/__init__.py @@ -0,0 +1,16 @@ +"""Repo-level dependency inference engine.""" + +from __future__ import annotations + +__all__ = ["DependencyGraph", "RepoNode", "build_graph"] + + +def __getattr__(name: str): + if name in __all__: + from .build import DependencyGraph, RepoNode, build_graph + return { + "DependencyGraph": DependencyGraph, + "RepoNode": RepoNode, + "build_graph": build_graph, + }[name] + raise AttributeError(name) diff --git a/client-plugin/server/src/index_graph/graph/build.py b/client-plugin/server/src/index_graph/graph/build.py new file mode 100644 index 0000000..a40bc2a --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/build.py @@ -0,0 +1,341 @@ +"""Assemble repo trees + resolvers into a DependencyGraph.""" +from __future__ import annotations + +import configparser +import json +import os +import tomllib +import multiprocessing +from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed +from collections.abc import Callable, Iterator, Mapping +from dataclasses import dataclass, field, replace +from pathlib import Path +from time import perf_counter + +from . import cache as _cache +from .edges import Edge, build_index, resolve_edges +from .description import description as _description +from .walk import ( + PreloadedFileValue, + cached_file_scope, + read_source_text, + source_reuse_complete, + walk_files, +) +from .resolvers import ALL_RESOLVERS +from .resolvers.base import RawEdge +from .roles import derive_roles + +make_lookup = _cache.make_lookup + +@dataclass(frozen=True) +class RepoNode: + name: str + path: str + ecosystems: tuple[str, ...] + exposed_names: frozenset[str] + description: str + markers: frozenset[str] + +@dataclass(frozen=True) +class DependencyGraph: + repos: tuple[RepoNode, ...] + edges: tuple[Edge, ...] + roles: dict[str, tuple[str, ...]] + warnings: tuple[str, ...] + cache_summary: dict[str, object] | None = field(default=None, compare=False) + +@dataclass(frozen=True) +class GraphProgress: + """Observed work, never an estimate of source coverage or semantic truth.""" + + phase: str + completed_repos: int + total_repos: int + elapsed_ms: int + +@dataclass(frozen=True) +class _RepoBuild: + name: str + node: RepoNode + exposed_names: set[str] + raw_edges: list[RawEdge] + markers: set[str] + cache_outcome: str = "bypassed" + cache_reason: str = "not_recorded" + cache_written: bool = False + cache_stats: dict[str, int] = field(default_factory=dict, compare=False) + +def detect_markers(repo_root: Path, exposed: set[str]) -> set[str]: + mk: set[str] = set() + if exposed: + mk.add("published") + pp = repo_root / "pyproject.toml" + if pp.is_file(): + try: + data = tomllib.loads(read_source_text(pp, encoding="utf-8", errors="replace")) + if data.get("project", {}).get("scripts") or \ + data.get("project", {}).get("entry-points"): + mk.add("entry") + except (tomllib.TOMLDecodeError, OSError): + pass + cfg = repo_root / "setup.cfg" + if cfg.is_file(): + try: + cp = configparser.ConfigParser() + cp.read_string(read_source_text(cfg, encoding="utf-8", errors="strict")) + if cp.has_option("options.entry_points", "console_scripts"): + mk.add("entry") + except (configparser.Error, OSError): + pass + pj = repo_root / "package.json" + if pj.is_file(): + try: + if json.loads(read_source_text(pj, encoding="utf-8", errors="replace")).get("bin"): + mk.add("entry") + except (json.JSONDecodeError, OSError): + pass + if any(walk_files(repo_root, names=("__main__.py",), stop_at_nested_repos=True)): + mk.add("entry") + return mk + +def _default_jobs() -> int: + return min(32, (os.cpu_count() or 4) * 5) + +def _build_one_repo(item: tuple[str, Path], resolvers) -> _RepoBuild: + name, root = item + ecos: list[str] = [] + names: set[str] = set() + raws: list[RawEdge] = [] + for resolver in resolvers: + if resolver.matches(root): + ecos.append(resolver.name) + names |= resolver.exposed_names(root) + raws += resolver.raw_edges(root) + mk = detect_markers(root, names) + node = RepoNode(name, str(root), tuple(ecos), frozenset(names), _description(root), frozenset(mk)) + return _RepoBuild(name, node, names, raws, mk) + +def _repo_build_to_json(build: _RepoBuild) -> dict: + return { + "node": { + "name": build.node.name, + "path": build.node.path, + "ecosystems": list(build.node.ecosystems), + "exposed_names": sorted(build.node.exposed_names), + "description": build.node.description, + "markers": sorted(build.node.markers), + }, + "exposed_names": sorted(build.exposed_names), + "raw_edges": [ + { + "target_name": edge.target_name, + "signal": edge.signal, + "evidence_file": edge.evidence_file, + "evidence_line": edge.evidence_line, + "raw_spec": edge.raw_spec, + } + for edge in build.raw_edges + ], + "markers": sorted(build.markers), + } + +def _repo_build_from_json(data: dict) -> _RepoBuild: + node = data["node"] + return _RepoBuild( + name=str(node["name"]), + node=RepoNode( + name=str(node["name"]), + path=str(node["path"]), + ecosystems=tuple(str(item) for item in node.get("ecosystems", ())), + exposed_names=frozenset(str(item) for item in node.get("exposed_names", ())), + description=str(node.get("description", "")), + markers=frozenset(str(item) for item in node.get("markers", ())), + ), + exposed_names=set(str(item) for item in data.get("exposed_names", ())), + raw_edges=[ + RawEdge( + str(edge["target_name"]), + str(edge["signal"]), + str(edge["evidence_file"]), + edge.get("evidence_line"), + str(edge["raw_spec"]), + ) + for edge in data.get("raw_edges", ()) + ], + markers=set(str(item) for item in data.get("markers", ())), + ) + +def _load_or_build_one_repo( + item: tuple[str, Path], + resolvers, + *, + use_cache: bool, + file_list: PreloadedFileValue | None = None, +) -> _RepoBuild: + name, requested_root = item + source_item = (name, requested_root.resolve()) + cache_stats = _cache.empty_repo_cache_stats() + + def presented(built: _RepoBuild) -> _RepoBuild: + return replace(built, node=replace(built.node, path=str(requested_root))) + + cache_allowed = use_cache and _cache.resolvers_use_shared_source_reads(resolvers) + scope_files = {source_item[1]: file_list} if file_list is not None else None + with cached_file_scope(scope_files): + if not cache_allowed: + build_started = perf_counter() + built = _build_one_repo(source_item, resolvers) + _cache.add_cache_timing(cache_stats, "fresh_build", build_started) + return presented(replace( + built, + cache_outcome="bypassed", + cache_reason="disabled_or_unsupported", + cache_stats=cache_stats, + )) + lookup = make_lookup(name, source_item[1], resolvers, stats=cache_stats) + cached, cache_outcome, cache_reason = _cache.read_repo_build_with_outcome(lookup, stats=cache_stats) + if cached is not None: + rehydrate_started = perf_counter() + try: + built = _repo_build_from_json(cached) + _cache.add_cache_timing(cache_stats, "cache_rehydrate", rehydrate_started) + return presented(replace( + built, + cache_outcome="hit", + cache_reason=cache_reason, + cache_stats=cache_stats, + )) + except (KeyError, TypeError, ValueError): + _cache.add_cache_timing(cache_stats, "cache_rehydrate", rehydrate_started) + cache_outcome = "invalid" + cache_reason = "build_payload" + build_started = perf_counter() + built = _build_one_repo(source_item, resolvers) + _cache.add_cache_timing(cache_stats, "fresh_build", build_started) + cache_written = False + if source_reuse_complete(): + cache_written = _cache.write_repo_build(lookup, _repo_build_to_json(built), stats=cache_stats) + return presented(replace( + built, + cache_outcome=cache_outcome, + cache_reason=cache_reason, + cache_written=cache_written, + cache_stats=cache_stats, + )) + +def _collect_repos( + items: list[tuple[str, Path]], + resolvers, + jobs: int, + *, + use_cache: bool, + file_lists: Mapping[str, PreloadedFileValue] | None = None, + executor: str = "thread", +) -> Iterator[_RepoBuild]: + if jobs <= 1 or len(items) <= 1: + for item in items: + file_list = file_lists.get(item[0]) if file_lists is not None else None + yield _load_or_build_one_repo(item, resolvers, use_cache=use_cache, + file_list=file_list) + return + # Spawn avoids inheriting locks or application state from an MCP host. The + # Python API retains threads by default for local/custom resolver objects. + pool_context = ( + ProcessPoolExecutor(max_workers=jobs, mp_context=multiprocessing.get_context("spawn")) + if executor == "process" else ThreadPoolExecutor(max_workers=jobs) + ) + with pool_context as pool: + pending = [ + pool.submit( + _load_or_build_one_repo, + item, + resolvers, + use_cache=use_cache, + file_list=file_lists.get(item[0]) if file_lists is not None else None, + ) + for item in items + ] + try: + for future in as_completed(pending): + yield future.result() + except BaseException: + # A failed scan or cancelled consumer cannot use any later result. + # Stop queued work before the context waits for active workers. + for future in pending: + future.cancel() + raise + +def build_graph( + repo_paths: dict[str, Path], + resolvers=ALL_RESOLVERS, + *, + jobs: int | None = None, + use_cache: bool = True, + on_progress: Callable[[GraphProgress], None] | None = None, + executor: str = "thread", + file_lists: Mapping[str, PreloadedFileValue] | None = None, +) -> DependencyGraph: + """Build complete dependency evidence; optional progress runs in the caller. + + Process workers are opt-in for Python callers, require picklable resolvers + and a guarded main entrypoint, and default to at most four workers. CLI/MCP + entrypoints select them to avoid Python parser contention across repos. + """ + if executor not in {"thread", "process"}: + raise ValueError("executor must be 'thread' or 'process'") + nodes: list[RepoNode] = [] + exposed: dict[str, set[str]] = {} + repo_raw: dict[str, list[RawEdge]] = {} + markers: dict[str, set[str]] = {} + cache_summary = _cache.empty_cache_summary() + default_jobs = min(4, os.cpu_count() or 1) if executor == "process" else _default_jobs() + worker_count = default_jobs if jobs is None else max(1, jobs) + started = perf_counter() + + def report(phase: str) -> None: + if on_progress is not None: + on_progress(GraphProgress(phase, len(nodes), len(repo_paths), + int((perf_counter() - started) * 1000))) + + try: + report("building") + for built in _collect_repos( + sorted(repo_paths.items()), resolvers, worker_count, use_cache=use_cache, + file_lists=file_lists, + executor=executor, + ): + _cache.record_cache_summary( + cache_summary, + built.cache_outcome, + built.cache_reason, + built.cache_written, + built.cache_stats, + ) + exposed[built.name] = built.exposed_names + repo_raw[built.name] = built.raw_edges + markers[built.name] = built.markers + nodes.append(built.node) + report("building") + + # Completion order is for observability only. Preserve stable graph order. + nodes.sort(key=lambda node: node.name) + exposed = dict(sorted(exposed.items())) + repo_raw = dict(sorted(repo_raw.items())) + markers = dict(sorted(markers.items())) + report("resolving") + index = build_index(exposed) + edges, warnings = resolve_edges(repo_raw, index) + roles = derive_roles(set(repo_paths), edges, markers) + graph = DependencyGraph( + tuple(nodes), + tuple(edges), + roles, + tuple(warnings), + _cache.final_cache_summary(cache_summary), + ) + except BaseException: + report("failed") + raise + report("complete") + return graph diff --git a/client-plugin/server/src/index_graph/graph/cache.py b/client-plugin/server/src/index_graph/graph/cache.py new file mode 100644 index 0000000..7a2a0c0 --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/cache.py @@ -0,0 +1,239 @@ +"""Per-repo graph resolver cache. + +The cache stores derived resolver facts, never raw source. A repo entry is valid +only for the same repo key, resolved path, resolver signature, graph-relevant +content fingerprint, and resolver source-read contract. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from time import perf_counter +from types import CodeType +from typing import Any + +from .walk import read_source_bytes +from .resolvers import BUILTIN_SHARED_SOURCE_READER_TYPES +from .cache_metrics import ( + add_cache_stat, + add_cache_timing, + empty_cache_summary, + empty_repo_cache_stats, + final_cache_summary, + record_cache_summary, +) +from .. import __version__ +from ..freshness.fingerprint import repo_fingerprint + +SCHEMA = "index.graph-repo-cache/v1" +CACHE_KEY_VERSION = "repo-build/v4" +_DESCRIPTION_NAMES = ("README.md", "README.rst", "README.txt", "readme.md") + + +@dataclass(frozen=True) +class RepoCacheLookup: + key: str + path: Path + repo_name: str + repo_path: str + fingerprint: str + resolver_signature: tuple[str, ...] + + +def _cache_dir() -> Path: + raw = os.environ.get("INDEX_GRAPH_REPO_CACHE_DIR") + if raw: + return Path(raw) + raw = os.environ.get("INDEX_CACHE_DIR") + if raw: + return Path(raw) / "graph-repos" + base = os.environ.get("LOCALAPPDATA") + if base: + return Path(base) / "index_graph" / "cache" / "graph-repos" + return Path.home() / ".cache" / "index_graph" / "graph-repos" + + +def _sha256_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _implementation_value(value): + """Encode immutable code values without marshal's object-reference state.""" + if isinstance(value, CodeType): + fields = ("co_argcount", "co_posonlyargcount", "co_kwonlyargcount", + "co_nlocals", "co_stacksize", "co_flags", "co_code", + "co_consts", "co_names", "co_varnames", "co_freevars", + "co_cellvars", "co_exceptiontable") + return ["code", [[name, _implementation_value(getattr(value, name, None))] + for name in fields]] + if isinstance(value, tuple): + return ["tuple", [_implementation_value(item) for item in value]] + if isinstance(value, frozenset): + items = [_implementation_value(item) for item in value] + return ["frozenset", sorted(items, key=lambda item: json.dumps(item))] + if isinstance(value, bytes): + return ["bytes", value.hex()] + return [type(value).__name__, repr(value)] + + +def _resolver_signature(resolvers) -> tuple[str, ...]: + parts = [] + for resolver in resolvers: + cls = type(resolver) + name = str(getattr(resolver, "name", cls.__name__)) + implementation = hashlib.sha256() + implementation.update(str(sys.implementation.cache_tag).encode("utf-8")) + for method_name in sorted(dir(cls)): + method = getattr(cls, method_name, None) + code = getattr(method, "__code__", None) + if code is not None: + implementation.update(method_name.encode("utf-8")) + implementation.update(json.dumps(_implementation_value(code), + separators=(",", ":")).encode("utf-8")) + # Custom resolvers with external configuration can invalidate their + # derived facts explicitly. Package version covers shipped helper changes. + implementation.update(str(getattr(resolver, "cache_version", "1")).encode("utf-8")) + parts.append(f"{name}:{cls.__module__}.{cls.__qualname__}:{implementation.hexdigest()}") + return tuple(sorted(parts)) + + +def resolver_uses_shared_source_reads(resolver) -> bool: + """Whether a resolver can safely participate in persistent repo caching.""" + if type(resolver) in BUILTIN_SHARED_SOURCE_READER_TYPES: + return True + return getattr(resolver, "uses_shared_source_reader", False) is True + + +def resolvers_use_shared_source_reads(resolvers) -> bool: + return all(resolver_uses_shared_source_reads(resolver) for resolver in resolvers) + + +def _file_digest(path: Path, stats: dict[str, int] | None = None) -> str: + started = perf_counter() + try: + data = read_source_bytes(path) + add_cache_stat(stats, "fingerprint_description_files", 1) + add_cache_stat(stats, "fingerprint_description_bytes", len(data)) + return hashlib.sha256(data).hexdigest() + except OSError: + add_cache_stat(stats, "fingerprint_description_unreadable", 1) + return "unreadable" + finally: + add_cache_timing(stats, "description_read_hash", started) + + +def repo_graph_fingerprint(repo_root: Path, resolvers, stats: dict[str, int] | None = None) -> str: + """Fingerprint resolver-relevant content plus description files. + + `repo_fingerprint` covers files read by resolvers. The graph node also carries + README/package descriptions, so the repo graph cache includes those root docs + to avoid stale inventory text. + """ + root = Path(repo_root) + parts = [repo_fingerprint(root, resolvers, stats=stats)] + for name in _DESCRIPTION_NAMES: + path = root / name + if path.is_file(): + parts.append(f"{name}:{_file_digest(path, stats=stats)}") + else: + parts.append(f"{name}:missing") + return _sha256_text("|".join(parts)) + + +def make_lookup(repo_name: str, repo_root: Path, resolvers, stats: dict[str, int] | None = None) -> RepoCacheLookup: + root = Path(repo_root).resolve() + started = perf_counter() + fingerprint = repo_graph_fingerprint(root, resolvers, stats=stats) + add_cache_timing(stats, "fingerprint", started) + started = perf_counter() + signature = _resolver_signature(resolvers) + add_cache_timing(stats, "resolver_signature", started) + payload = { + "version": CACHE_KEY_VERSION, + "index_version": __version__, + "repo_name": repo_name, + "repo_path": str(root), + "fingerprint": fingerprint, + "resolver_signature": signature, + } + key = _sha256_text(json.dumps(payload, sort_keys=True, separators=(",", ":"))) + return RepoCacheLookup( + key=key, + path=_cache_dir() / f"{key}.json", + repo_name=repo_name, + repo_path=str(root), + fingerprint=fingerprint, + resolver_signature=signature, + ) + + +def read_repo_build_with_outcome( + lookup: RepoCacheLookup, stats: dict[str, int] | None = None +) -> tuple[dict[str, Any] | None, str, str]: + started = perf_counter() + try: + raw = lookup.path.read_text(encoding="utf-8") + except FileNotFoundError: + add_cache_timing(stats, "cache_read", started) + return None, "miss", "not_found" + except OSError: + add_cache_timing(stats, "cache_read", started) + return None, "invalid", "unreadable" + add_cache_timing(stats, "cache_read", started) + started = perf_counter() + try: + data = json.loads(raw) + except (json.JSONDecodeError, ValueError): + add_cache_timing(stats, "cache_decode", started) + return None, "invalid", "invalid_json" + add_cache_timing(stats, "cache_decode", started) + if data.get("schema") != SCHEMA: + return None, "invalid", "schema" + if data.get("repo_name") != lookup.repo_name: + return None, "invalid", "repo_name" + if data.get("repo_path") != lookup.repo_path: + return None, "invalid", "repo_path" + if data.get("fingerprint") != lookup.fingerprint: + return None, "invalid", "fingerprint" + if tuple(data.get("resolver_signature") or ()) != lookup.resolver_signature: + return None, "invalid", "resolver_signature" + build = data.get("build") + if not isinstance(build, dict): + return None, "invalid", "build" + return build, "hit", "hit" + + +def read_repo_build(lookup: RepoCacheLookup) -> dict[str, Any] | None: + build, _outcome, _reason = read_repo_build_with_outcome(lookup) + return build + + +def write_repo_build( + lookup: RepoCacheLookup, build: dict[str, Any], stats: dict[str, int] | None = None +) -> bool: + started = perf_counter() + payload = { + "schema": SCHEMA, + "version": CACHE_KEY_VERSION, + "repo_name": lookup.repo_name, + "repo_path": lookup.repo_path, + "fingerprint": lookup.fingerprint, + "resolver_signature": list(lookup.resolver_signature), + "build": build, + } + try: + lookup.path.parent.mkdir(parents=True, exist_ok=True) + lookup.path.write_text( + json.dumps(payload, sort_keys=True, separators=(",", ":")), + encoding="utf-8", + ) + return True + except OSError: + return False + finally: + add_cache_timing(stats, "cache_write", started) diff --git a/client-plugin/server/src/index_graph/graph/cache_metrics.py b/client-plugin/server/src/index_graph/graph/cache_metrics.py new file mode 100644 index 0000000..ba747c7 --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/cache_metrics.py @@ -0,0 +1,100 @@ +"""Aggregate graph-cache telemetry without source or path disclosure.""" +from __future__ import annotations + +from time import perf_counter + +CACHE_STAGE_KEYS = ( + "fingerprint", + "fingerprint_walk", + "fingerprint_read_hash", + "description_read_hash", + "resolver_signature", + "cache_read", + "cache_decode", + "cache_rehydrate", + "fresh_build", + "cache_write", +) +FINGERPRINT_KEYS = ( + "files", + "bytes", + "unreadable", + "description_files", + "description_bytes", + "description_unreadable", +) + + +def empty_repo_cache_stats() -> dict[str, int]: + stats = {f"{key}_ms": 0 for key in CACHE_STAGE_KEYS} + stats.update({f"fingerprint_{key}": 0 for key in FINGERPRINT_KEYS}) + return stats + + +def add_cache_stat(stats: dict[str, int] | None, key: str, value: int) -> None: + if stats is not None: + stats[key] = int(stats.get(key, 0)) + int(value) + + +def add_cache_timing(stats: dict[str, int] | None, key: str, started: float) -> None: + add_cache_stat(stats, f"{key}_ms", int((perf_counter() - started) * 1000)) + + +def empty_cache_summary() -> dict[str, object]: + return { + "hits": 0, + "misses": 0, + "invalid": 0, + "bypassed": 0, + "writes": 0, + "reasons": {}, + "stage_timings_ms": {key: 0 for key in CACHE_STAGE_KEYS}, + "fingerprint": {key: 0 for key in FINGERPRINT_KEYS}, + } + + +def record_cache_summary( + summary: dict[str, object], + outcome: str, + reason: str, + written: bool, + stats: dict[str, int] | None = None, +) -> None: + outcome_to_key = {"hit": "hits", "miss": "misses", "invalid": "invalid", "bypassed": "bypassed"} + key = outcome_to_key.get(outcome, "invalid") + summary[key] = int(summary.get(key, 0)) + 1 + if written: + summary["writes"] = int(summary.get("writes", 0)) + 1 + reasons = summary.setdefault("reasons", {}) + if isinstance(reasons, dict): + reasons[reason or outcome] = int(reasons.get(reason or outcome, 0)) + 1 + stage_timings = summary.setdefault("stage_timings_ms", {}) + if isinstance(stage_timings, dict): + for stage_key in CACHE_STAGE_KEYS: + stage_timings[stage_key] = int(stage_timings.get(stage_key, 0)) + int((stats or {}).get(f"{stage_key}_ms", 0)) + fingerprint = summary.setdefault("fingerprint", {}) + if isinstance(fingerprint, dict): + for stat_key in FINGERPRINT_KEYS: + fingerprint[stat_key] = int(fingerprint.get(stat_key, 0)) + int((stats or {}).get(f"fingerprint_{stat_key}", 0)) + + +def final_cache_summary(summary: dict[str, object]) -> dict[str, object]: + reasons = summary.get("reasons") + stage_timings = summary.get("stage_timings_ms") + fingerprint = summary.get("fingerprint") + return { + "hits": int(summary.get("hits", 0)), + "misses": int(summary.get("misses", 0)), + "invalid": int(summary.get("invalid", 0)), + "bypassed": int(summary.get("bypassed", 0)), + "writes": int(summary.get("writes", 0)), + "reasons": dict(sorted(reasons.items())) if isinstance(reasons, dict) else {}, + "stage_timings_ms": { + key: int(stage_timings.get(key, 0)) if isinstance(stage_timings, dict) else 0 + for key in CACHE_STAGE_KEYS + }, + "fingerprint": { + key: int(fingerprint.get(key, 0)) if isinstance(fingerprint, dict) else 0 + for key in FINGERPRINT_KEYS + }, + } diff --git a/client-plugin/server/src/index_graph/graph/cycles.py b/client-plugin/server/src/index_graph/graph/cycles.py new file mode 100644 index 0000000..288994d --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/cycles.py @@ -0,0 +1,73 @@ +"""Detect dependency cycles (strongly-connected components) over internal edges. + +Tarjan's SCC is linear; repo-level graphs are tiny (hundreds of nodes), so the +recursive form is safe and deterministic (inputs are sorted before traversal).""" +from __future__ import annotations + +from collections.abc import Sequence + +from .edges import Edge + + +def _adjacency(edges: Sequence[Edge]) -> dict[str, list[str]]: + adj: dict[str, list[str]] = {} + for e in edges: + if e.external or e.to_repo is None: + continue + adj.setdefault(e.from_repo, []) + adj.setdefault(e.to_repo, []) + if e.to_repo not in adj[e.from_repo]: + adj[e.from_repo].append(e.to_repo) + return adj + + +def find_cycles(edges: Sequence[Edge]) -> list[tuple[str, ...]]: + """Node sets of each dependency cycle, deterministically sorted. An SCC with + >1 node, or a self-loop, is a cycle; a pure DAG returns [].""" + adj = _adjacency(edges) + self_loops = {e.from_repo for e in edges + if not e.external and e.to_repo == e.from_repo} + counter = [0] + stack: list[str] = [] + on_stack: set[str] = set() + index: dict[str, int] = {} + low: dict[str, int] = {} + sccs: list[list[str]] = [] + + def connect(v: str) -> None: + index[v] = low[v] = counter[0] + counter[0] += 1 + stack.append(v) + on_stack.add(v) + for w in adj.get(v, []): + if w not in index: + connect(w) + low[v] = min(low[v], low[w]) + elif w in on_stack: + low[v] = min(low[v], index[w]) + if low[v] == index[v]: + comp: list[str] = [] + while True: + w = stack.pop() + on_stack.discard(w) + comp.append(w) + if w == v: + break + sccs.append(comp) + + for v in sorted(adj): + if v not in index: + connect(v) + + cycles = [tuple(sorted(c)) for c in sccs + if len(c) > 1 or (len(c) == 1 and c[0] in self_loops)] + return sorted(cycles) + + +def cycle_edge_keys(edges: Sequence[Edge], + cycles: Sequence[tuple[str, ...]]) -> set[tuple[str, str]]: + """(from, to) of internal edges whose endpoints share a cycle.""" + member: dict[str, tuple[str, ...]] = {n: c for c in cycles for n in c} + return {(e.from_repo, e.to_repo) for e in edges + if not e.external and e.to_repo is not None + and e.from_repo in member and member[e.from_repo] == member.get(e.to_repo)} diff --git a/client-plugin/server/src/index_graph/graph/description.py b/client-plugin/server/src/index_graph/graph/description.py new file mode 100644 index 0000000..27b1bdd --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/description.py @@ -0,0 +1,42 @@ +"""Repository description extraction for graph nodes.""" +from __future__ import annotations + +import json +import re +import tomllib +from pathlib import Path + +from .walk import read_source_text + +_PARA = re.compile(r"\n\s*\n") + + +def description(repo_root: Path) -> str: + for readme in ("README.md", "README.rst", "README.txt", "readme.md"): + p = repo_root / readme + if p.is_file(): + try: + text = read_source_text(p, encoding="utf-8", errors="replace").strip() + except OSError: + continue + for block in _PARA.split(text): + b = block.strip() + if b and not b.startswith("#") and not b.startswith("!["): + return " ".join(b.split())[:300] + pp = repo_root / "pyproject.toml" + if pp.is_file(): + try: + d = tomllib.loads(read_source_text(pp, encoding="utf-8", errors="replace")).get("project", {}) + if d.get("description"): + return str(d["description"]) + except (tomllib.TOMLDecodeError, OSError): + pass + pj = repo_root / "package.json" + if pj.is_file(): + try: + d = json.loads(read_source_text(pj, encoding="utf-8", errors="replace")) + if d.get("description"): + return str(d["description"]) + except (json.JSONDecodeError, OSError): + pass + return "(no description)" diff --git a/client-plugin/server/src/index_graph/graph/edges.py b/client-plugin/server/src/index_graph/graph/edges.py new file mode 100644 index 0000000..924b87e --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/edges.py @@ -0,0 +1,97 @@ +"""Resolve RawEdges into evidence-carrying repo->repo Edges.""" +from __future__ import annotations + +from dataclasses import dataclass + +from .resolvers.base import RawEdge, normalize_name + + +@dataclass(frozen=True) +class Signal: + kind: str # "manifest" | "import" + evidence_file: str + evidence_line: int | None + raw_spec: str + + +@dataclass(frozen=True) +class Edge: + from_repo: str + to_repo: str | None + target_name: str + external: bool + confidence: str # "high" | "moderate" | "low" + signals: tuple[Signal, ...] + + +def build_index(exposed: dict[str, set[str]]) -> dict[str, list[str]]: + index: dict[str, list[str]] = {} + for repo, names in exposed.items(): + for n in names: + key = normalize_name(n) + bucket = index.setdefault(key, []) + if repo not in bucket: + bucket.append(repo) + return index + + +def _resolve_target(index: dict[str, list[str]], norm_target: str) -> tuple[str, list[str]]: + """Return (canonical_key, candidate_repos). + + Exact match wins. Otherwise, for a slash-containing target (a path-like name, + e.g. a Go import path), fall back to the longest exposed name that is a + segment-aligned prefix of the target. Unmatched targets keep their own name + (they become external edges). + """ + if norm_target in index: + return norm_target, index[norm_target] + if "/" in norm_target: + best: str | None = None + for key in index: + if "/" in key and (norm_target == key or norm_target.startswith(key + "/")): + if best is None or len(key) > len(best): + best = key + if best is not None: + return best, index[best] + return norm_target, [] + + +def _grade(signals: list[Signal], ambiguous: bool, target: str, short_len: int) -> str: + if ambiguous or len(normalize_name(target)) <= short_len: + return "low" + kinds = {s.kind for s in signals} + return "high" if {"manifest", "import"} <= kinds else "moderate" + + +def resolve_edges(repo_raw: dict[str, list[RawEdge]], index: dict[str, list[str]], + short_len: int = 2) -> tuple[list[Edge], list[str]]: + warnings: list[str] = [] + # group RawEdges by (from_repo, resolved_target_or_external_name) + grouped: dict[tuple[str, str | None, str], list[Signal]] = {} + ambiguous_keys: set[tuple[str, str | None, str]] = set() + for frm, raws in repo_raw.items(): + for r in raws: + canon, candidates = _resolve_target(index, normalize_name(r.target_name)) + internal = [c for c in candidates if c != frm] + sig = Signal(r.signal, r.evidence_file, r.evidence_line, r.raw_spec) + if not candidates: + key = (frm, None, canon) # external + elif not internal: + continue # self-edge only -> drop + else: + to = sorted(internal)[0] + key = (frm, to, canon) + if len(internal) > 1: + ambiguous_keys.add(key) + warnings.append( + f"ambiguous: {frm} -> {r.target_name!r} matches {sorted(internal)}") + grouped.setdefault(key, []).append(sig) + + edges: list[Edge] = [] + for (frm, to, target), signals in grouped.items(): + external = to is None + conf = "moderate" if external else _grade( + signals, (frm, to, target) in ambiguous_keys, target, short_len) + edges.append(Edge(frm, to, target, external, conf, tuple(signals))) + edges.sort(key=lambda e: (e.from_repo, e.to_repo or "", e.target_name)) + return edges, warnings diff --git a/client-plugin/server/src/index_graph/graph/progress.py b/client-plugin/server/src/index_graph/graph/progress.py new file mode 100644 index 0000000..ae2526f --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/progress.py @@ -0,0 +1,27 @@ +"""Rate-limited graph diagnostics, separate from CLI/MCP result streams.""" +from __future__ import annotations + +import json +import sys +from dataclasses import asdict + +from .build import GraphProgress + + +def stderr_progress(): + last_phase = None + last_ms = -1000 + + def report(event: GraphProgress) -> None: + nonlocal last_phase, last_ms + if event.phase == last_phase and event.elapsed_ms - last_ms < 1000: + return + last_phase, last_ms = event.phase, event.elapsed_ms + try: + print(json.dumps({"schema": "index.graph-progress/v1", **asdict(event)}), + file=sys.stderr, flush=True) + except OSError: + # Losing a diagnostic sink must not change dependency evidence. + pass + + return report diff --git a/client-plugin/server/src/index_graph/graph/resolvers/__init__.py b/client-plugin/server/src/index_graph/graph/resolvers/__init__.py new file mode 100644 index 0000000..ce5066f --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/resolvers/__init__.py @@ -0,0 +1,25 @@ +"""Per-ecosystem dependency resolvers.""" +from .cpp import CppResolver +from .csharp import CSharpResolver +from .go import GoResolver +from .java import JavaResolver +from .javascript import JavaScriptResolver +from .php import PhpResolver +from .python import PythonResolver +from .ruby import RubyResolver +from .rust import RustResolver + +ALL_RESOLVERS = (PythonResolver(), JavaScriptResolver(), RustResolver(), GoResolver(), + JavaResolver(), CSharpResolver(), RubyResolver(), PhpResolver(), CppResolver()) + +BUILTIN_SHARED_SOURCE_READER_TYPES = ( + PythonResolver, + JavaScriptResolver, + RustResolver, + GoResolver, + JavaResolver, + CSharpResolver, + RubyResolver, + PhpResolver, + CppResolver, +) diff --git a/client-plugin/server/src/index_graph/graph/resolvers/base.py b/client-plugin/server/src/index_graph/graph/resolvers/base.py new file mode 100644 index 0000000..ea70a75 --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/resolvers/base.py @@ -0,0 +1,31 @@ +"""Resolver seam: the generic interface every ecosystem implements.""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + + +@dataclass(frozen=True) +class RawEdge: + target_name: str # name imported/declared, e.g. "requests", "@scope/pkg" + signal: str # "manifest" | "import" + evidence_file: str # repo-relative path of the witnessing file + evidence_line: int | None # line number where cheaply known, else None + raw_spec: str # literal text witnessed (dep spec or import line) + + +def normalize_name(name: str) -> str: + """Lowercase and unify '_'/'-' so a dist name matches an import name.""" + return name.strip().lower().replace("_", "-") + + +class Resolver(Protocol): + # Optional runtime opt-in for custom resolvers. Set + # uses_shared_source_reader = True only when every graph-relevant source read + # goes through index_graph.graph.walk.read_source_bytes/read_source_text. + name: str + + def matches(self, repo_root: Path) -> bool: ... + def exposed_names(self, repo_root: Path) -> set[str]: ... + def raw_edges(self, repo_root: Path) -> list[RawEdge]: ... diff --git a/client-plugin/server/src/index_graph/graph/resolvers/cpp.py b/client-plugin/server/src/index_graph/graph/resolvers/cpp.py new file mode 100644 index 0000000..48f6f5c --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/resolvers/cpp.py @@ -0,0 +1,110 @@ +"""C/C++ ecosystem resolver: CMake target links + #include directives. + +Best-effort only. C/C++ has no single canonical dependency manifest. +This resolver reads: + - CMakeLists.txt for project/library/executable names (exposed_names) + - target_link_libraries(...) for manifest edges + - add_subdirectory(...) for manifest edges + - #include "..." and #include <...> for import edges + +Keywords PUBLIC, PRIVATE, INTERFACE in target_link_libraries are skipped; +they are not dependency targets. +""" +from __future__ import annotations + +import re +from pathlib import Path + +from ..walk import read_source_text, walk_files +from .base import RawEdge + +# CMake command patterns (case-sensitive as written in CMakeLists.txt) +_PROJECT = re.compile(r"^\s*project\s*\(\s*([^)\s]+)", re.IGNORECASE) +_ADD_LIB = re.compile(r"^\s*add_library\s*\(\s*([^)\s]+)", re.IGNORECASE) +_ADD_EXE = re.compile(r"^\s*add_executable\s*\(\s*([^)\s]+)", re.IGNORECASE) +_ADD_SUBDIR = re.compile(r"^\s*add_subdirectory\s*\(\s*([^)\s]+)", re.IGNORECASE) +_TARGET_LINK = re.compile(r"^\s*target_link_libraries\s*\(", re.IGNORECASE) + +# Keywords that are scope modifiers, not library names +_CMAKE_KEYWORDS = frozenset({"PUBLIC", "PRIVATE", "INTERFACE"}) + +# #include directives +_INCLUDE = re.compile(r'^\s*#\s*include\s*["<]([^">]+)[">]') + + +class CppResolver: + name = "cpp" + # files whose content feeds the graph (read by the freshness fingerprint) + fingerprint_names = ("CMakeLists.txt",) + fingerprint_suffixes = (".c", ".cc", ".cpp", ".cxx", ".h", ".hpp") + + def matches(self, repo_root: Path) -> bool: + return any(True for _ in walk_files( + repo_root, names=("CMakeLists.txt",), stop_at_nested_repos=True)) + + def exposed_names(self, repo_root: Path) -> set[str]: + names: set[str] = set() + for cmake in walk_files(repo_root, names=("CMakeLists.txt",), stop_at_nested_repos=True): + try: + lines = read_source_text(cmake, encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + for line in lines: + for pat in (_PROJECT, _ADD_LIB, _ADD_EXE): + m = pat.match(line) + if m: + names.add(m.group(1)) + break + return names + + def raw_edges(self, repo_root: Path) -> list[RawEdge]: + edges: list[RawEdge] = [] + + # Manifest edges from CMakeLists.txt + for cmake in walk_files(repo_root, names=("CMakeLists.txt",), stop_at_nested_repos=True): + try: + lines = read_source_text(cmake, encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + rel = cmake.relative_to(repo_root).as_posix() + i, n = 0, len(lines) + while i < n: + line = lines[i] + # add_subdirectory(dir): dir is treated as a target name + m = _ADD_SUBDIR.match(line) + if m: + edges.append(RawEdge(m.group(1), "manifest", rel, i + 1, line.strip())) + i += 1 + continue + # target_link_libraries(target [PUBLIC|PRIVATE|INTERFACE] lib1 lib2 ...), + # accumulated across lines until the closing paren (CMake spreads these). + if _TARGET_LINK.match(line): + start = i + buf = line + while ")" not in buf and i + 1 < n: + i += 1 + buf += " " + lines[i] + inner = buf.split("(", 1)[1].split(")", 1)[0] + toks = inner.split() + for token in toks[1:]: # toks[0] is the target itself + if token not in _CMAKE_KEYWORDS: + edges.append(RawEdge(token, "manifest", rel, start + 1, + lines[start].strip())) + i += 1 + continue + i += 1 + + # Import edges from C/C++ source files + src_suffixes = (".c", ".cc", ".cpp", ".cxx", ".h", ".hpp") + for src in walk_files(repo_root, suffixes=src_suffixes, stop_at_nested_repos=True): + try: + lines = read_source_text(src, encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + rel = src.relative_to(repo_root).as_posix() + for i, line in enumerate(lines, 1): + m = _INCLUDE.match(line) + if m: + edges.append(RawEdge(m.group(1), "import", rel, i, line.strip())) + + return edges diff --git a/client-plugin/server/src/index_graph/graph/resolvers/csharp.py b/client-plugin/server/src/index_graph/graph/resolvers/csharp.py new file mode 100644 index 0000000..dd899df --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/resolvers/csharp.py @@ -0,0 +1,77 @@ +"""C#/.NET ecosystem resolver: .csproj manifest + using-statement scan.""" +from __future__ import annotations + +import re +from pathlib import Path + +from ..walk import read_source_text, walk_files +from .base import RawEdge + +_ASSEMBLY_NAME = re.compile(r"\s*([^<]+)\s*") +_ROOT_NAMESPACE = re.compile(r"\s*([^<]+)\s*") +_PKG_REF = re.compile(r']*Include="([^"]+)"', re.IGNORECASE) +_PROJ_REF = re.compile(r']*Include="([^"]+)"', re.IGNORECASE) +_USING = re.compile(r"^\s*using\s+([\w.]+)\s*;") + + +class CSharpResolver: + name = "csharp" + # files whose content feeds the graph (read by the freshness fingerprint) + fingerprint_suffixes = (".csproj", ".cs") + + def matches(self, repo_root: Path) -> bool: + # walk_files is fail-closed (never raises on a permission-denied subdir) + # and pruned; rglob would propagate an OSError out and crash the graph build. + return any(walk_files(repo_root, suffixes=(".csproj",), stop_at_nested_repos=True)) + + def exposed_names(self, repo_root: Path) -> set[str]: + names: set[str] = set() + for csproj in walk_files(repo_root, suffixes=(".csproj",), stop_at_nested_repos=True): + names.add(csproj.stem) + try: + text = read_source_text(csproj, encoding="utf-8", errors="replace") + except OSError: + continue + for pattern in (_ASSEMBLY_NAME, _ROOT_NAMESPACE): + m = pattern.search(text) + if m: + names.add(m.group(1).strip()) + return names + + def raw_edges(self, repo_root: Path) -> list[RawEdge]: + edges: list[RawEdge] = [] + + # manifest edges: parse each .csproj for PackageReference and ProjectReference + for csproj in walk_files(repo_root, suffixes=(".csproj",), stop_at_nested_repos=True): + try: + lines = read_source_text(csproj, encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + rel = csproj.relative_to(repo_root).as_posix() + for i, line in enumerate(lines, 1): + m = _PKG_REF.search(line) + if m: + pkg = m.group(1).strip() + edges.append(RawEdge(pkg, "manifest", rel, i, line.strip())) + continue + m = _PROJ_REF.search(line) + if m: + # target is the stem of the referenced .csproj path + ref_stem = Path(m.group(1).replace("\\", "/")).stem + edges.append(RawEdge(ref_stem, "manifest", rel, i, line.strip())) + + # import edges: scan .cs files for using statements + for src in walk_files(repo_root, suffixes=(".cs",), stop_at_nested_repos=True): + try: + lines = read_source_text(src, encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + rel = src.relative_to(repo_root).as_posix() + for i, line in enumerate(lines, 1): + m = _USING.match(line) + if m: + # target is the top-level namespace segment + top = m.group(1).split(".")[0] + edges.append(RawEdge(top, "import", rel, i, line.strip())) + + return edges diff --git a/client-plugin/server/src/index_graph/graph/resolvers/go.py b/client-plugin/server/src/index_graph/graph/resolvers/go.py new file mode 100644 index 0000000..4ca77fb --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/resolvers/go.py @@ -0,0 +1,87 @@ +"""Go ecosystem resolver: go.mod requires + an import-path scan.""" +from __future__ import annotations + +import re +from pathlib import Path + +from ..walk import read_source_text, walk_files +from .base import RawEdge + +_MODULE = re.compile(r"^\s*module\s+(\S+)") +_REQUIRE_SINGLE = re.compile(r"^\s*require\s+(\S+)\s+\S+") +_IMPORT_SINGLE = re.compile(r'^\s*import\s+(?:[A-Za-z0-9_.]+\s+)?"([^"]+)"') +_GROUPED_IMPORT = re.compile(r'^\s*(?:[A-Za-z0-9_.]+\s+)?"([^"]+)"') + + +class GoResolver: + name = "go" + # files whose content feeds the graph (read by the freshness fingerprint) + fingerprint_names = ("go.mod",) + fingerprint_suffixes = (".go",) + + def matches(self, repo_root: Path) -> bool: + return (repo_root / "go.mod").is_file() + + def exposed_names(self, repo_root: Path) -> set[str]: + gm = repo_root / "go.mod" + try: + for line in read_source_text(gm, encoding="utf-8", errors="replace").splitlines(): + m = _MODULE.match(line) + if m: + return {m.group(1)} + except OSError: + pass + return set() + + def _require_paths(self, text: str) -> list[str]: + out: list[str] = [] + in_block = False + for line in text.splitlines(): + s = line.strip() + if s.startswith("require (") or s == "require (": + in_block = True + continue + if in_block: + if s == ")": + in_block = False + elif s and not s.startswith("//"): + out.append(s.split()[0]) + continue + m = _REQUIRE_SINGLE.match(line) + if m: + out.append(m.group(1)) + return out + + def raw_edges(self, repo_root: Path) -> list[RawEdge]: + edges: list[RawEdge] = [] + gm = repo_root / "go.mod" + if gm.is_file(): + try: + for path in self._require_paths(read_source_text(gm, encoding="utf-8", errors="replace")): + edges.append(RawEdge(path, "manifest", "go.mod", None, f"require {path}")) + except OSError: + pass + for src in walk_files(repo_root, suffixes=(".go",), stop_at_nested_repos=True): + try: + lines = read_source_text(src, encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + rel = src.relative_to(repo_root).as_posix() + in_block = False + for i, line in enumerate(lines, 1): + s = line.strip() + if s.startswith("import ("): + in_block = True + continue + if in_block: + if s == ")": + in_block = False + continue + m = _GROUPED_IMPORT.match(s) + if m: + edges.append(RawEdge(m.group(1), "import", rel, i, s)) + continue + m = _IMPORT_SINGLE.match(line) + if m: + edges.append(RawEdge(m.group(1), "import", rel, i, s)) + return edges diff --git a/client-plugin/server/src/index_graph/graph/resolvers/java.py b/client-plugin/server/src/index_graph/graph/resolvers/java.py new file mode 100644 index 0000000..0299214 --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/resolvers/java.py @@ -0,0 +1,92 @@ +"""Java ecosystem resolver: Maven pom.xml + best-effort Gradle. Manifest-only.""" +from __future__ import annotations + +import re +from pathlib import Path +from xml.etree import ElementTree as ET + +from ..walk import read_source_bytes, read_source_text, walk_files +from .base import RawEdge + +_GRADLE_FILES = ("build.gradle", "build.gradle.kts") +_GRADLE_DEP = re.compile( + r"""(?:implementation|api|compileOnly|runtimeOnly|testImplementation)\s*[(\s]\s*""" + r"""['"]([^'":]+:[^'":]+):[^'"]+['"]""") + + +def _local(tag: str) -> str: + return tag.rsplit("}", 1)[-1] # strip the {namespace} + + +def _pom_coords(pom: Path) -> tuple[str, str] | None: + """The pom's own (groupId, artifactId), falling back to .""" + try: + root = ET.fromstring(read_source_bytes(pom)) + except (ET.ParseError, OSError): + return None + group = artifact = parent_group = None + for child in root: + t = _local(child.tag) + if t == "groupId": + group = (child.text or "").strip() + elif t == "artifactId": + artifact = (child.text or "").strip() + elif t == "parent": + for pc in child: + if _local(pc.tag) == "groupId": + parent_group = (pc.text or "").strip() + group = group or parent_group + return (group, artifact) if (group and artifact) else None + + +class JavaResolver: + name = "java" + # files whose content feeds the graph (read by the freshness fingerprint); manifest-only + fingerprint_names = ("pom.xml", "build.gradle", "build.gradle.kts") + + def matches(self, repo_root: Path) -> bool: + return ((repo_root / "pom.xml").is_file() + or any((repo_root / g).is_file() for g in _GRADLE_FILES)) + + def exposed_names(self, repo_root: Path) -> set[str]: + names: set[str] = set() + for pom in walk_files(repo_root, names=("pom.xml",), stop_at_nested_repos=True): + coords = _pom_coords(pom) + if coords: + names.add(f"{coords[0]}:{coords[1]}") + return names + + def raw_edges(self, repo_root: Path) -> list[RawEdge]: + edges: list[RawEdge] = [] + for pom in walk_files(repo_root, names=("pom.xml",), stop_at_nested_repos=True): + try: + root = ET.fromstring(read_source_bytes(pom)) + except (ET.ParseError, OSError): + continue + rel = pom.relative_to(repo_root).as_posix() + for dep in root.iter(): + if _local(dep.tag) != "dependency": + continue + group = artifact = None + for c in dep: + t = _local(c.tag) + if t == "groupId": + group = (c.text or "").strip() + elif t == "artifactId": + artifact = (c.text or "").strip() + if group and artifact: + coord = f"{group}:{artifact}" + edges.append(RawEdge(coord, "manifest", rel, None, coord)) + for gf in _GRADLE_FILES: + gp = repo_root / gf + if not gp.is_file(): + continue + try: + lines = read_source_text(gp, encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + for i, line in enumerate(lines, 1): + m = _GRADLE_DEP.search(line) + if m: + edges.append(RawEdge(m.group(1), "manifest", gf, i, line.strip())) + return edges diff --git a/client-plugin/server/src/index_graph/graph/resolvers/javascript.py b/client-plugin/server/src/index_graph/graph/resolvers/javascript.py new file mode 100644 index 0000000..5971072 --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/resolvers/javascript.py @@ -0,0 +1,67 @@ +"""JavaScript/TypeScript resolver: package.json + conservative import scan.""" +from __future__ import annotations + +import json +import re +from pathlib import Path + +from ..walk import read_source_text, walk_files +from .base import RawEdge + +_EXTS = (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs") +_IMPORT = re.compile(r"""(?:import\s[^'"]*?from\s*|import\s*|require\(\s*|import\(\s*)['"]([^'"]+)['"]""") + + +def _bare_package(spec: str) -> str | None: + """Return the package name for a bare specifier, else None for relative/absolute paths.""" + if spec.startswith(".") or spec.startswith("/"): + return None + parts = spec.split("/") + if spec.startswith("@") and len(parts) >= 2: + return "/".join(parts[:2]) + return parts[0] + + +class JavaScriptResolver: + name = "javascript" + # files whose content feeds the graph (read by the freshness fingerprint) + fingerprint_names = ("package.json",) + fingerprint_suffixes = _EXTS + + def matches(self, repo_root: Path) -> bool: + return (repo_root / "package.json").is_file() + + def exposed_names(self, repo_root: Path) -> set[str]: + pj = repo_root / "package.json" + if not pj.is_file(): + return set() + try: + data = json.loads(read_source_text(pj, encoding="utf-8", errors="replace")) + except (json.JSONDecodeError, OSError): + return set() + name = data.get("name") + return {str(name)} if name else set() + + def raw_edges(self, repo_root: Path) -> list[RawEdge]: + edges: list[RawEdge] = [] + pj = repo_root / "package.json" + if pj.is_file(): + try: + data = json.loads(read_source_text(pj, encoding="utf-8", errors="replace")) + for field in ("dependencies", "devDependencies", "peerDependencies"): + for name, spec in (data.get(field, {}) or {}).items(): + edges.append(RawEdge(str(name), "manifest", "package.json", None, f"{name}: {spec}")) + except (json.JSONDecodeError, OSError): + pass + for src in walk_files(repo_root, suffixes=_EXTS, stop_at_nested_repos=True): + try: + lines = read_source_text(src, encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + rel = src.relative_to(repo_root).as_posix() + for i, line in enumerate(lines, 1): + for m in _IMPORT.finditer(line): + pkg = _bare_package(m.group(1)) + if pkg: + edges.append(RawEdge(pkg, "import", rel, i, line.strip())) + return edges diff --git a/client-plugin/server/src/index_graph/graph/resolvers/php.py b/client-plugin/server/src/index_graph/graph/resolvers/php.py new file mode 100644 index 0000000..ec25801 --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/resolvers/php.py @@ -0,0 +1,62 @@ +"""PHP ecosystem resolver: composer.json require/require-dev + use-statement scan.""" +from __future__ import annotations + +import json +import re +from pathlib import Path + +from ..walk import read_source_text, walk_files +from .base import RawEdge + +# `use Ns\Thing;`, and also `use function Ns\fn;` / `use const Ns\C;`: a symbol +# import still depends on namespace Ns, so the optional function/const modifier is +# consumed and the leading namespace segment is captured as the target. The trailing +# \s+ in the modifier keeps a namespace that merely starts with "function" (say +# `use functional\X;`) from being mistaken for the keyword. +_USE_STMT = re.compile( + r"^\s*use\s+(?:function\s+|const\s+)?\\?([A-Za-z_][A-Za-z0-9_]*)(?:\\[^;]*)?\s*;" +) + + +class PhpResolver: + name = "php" + # files whose content feeds the graph (read by the freshness fingerprint) + fingerprint_names = ("composer.json",) + fingerprint_suffixes = (".php",) + + def matches(self, repo_root: Path) -> bool: + return (repo_root / "composer.json").is_file() + + def exposed_names(self, repo_root: Path) -> set[str]: + cj = repo_root / "composer.json" + if not cj.is_file(): + return set() + try: + data = json.loads(read_source_text(cj, encoding="utf-8", errors="replace")) + except (json.JSONDecodeError, OSError): + return set() + name = data.get("name") + return {str(name)} if name else set() + + def raw_edges(self, repo_root: Path) -> list[RawEdge]: + edges: list[RawEdge] = [] + cj = repo_root / "composer.json" + if cj.is_file(): + try: + data = json.loads(read_source_text(cj, encoding="utf-8", errors="replace")) + for field in ("require", "require-dev"): + for pkg, spec in (data.get(field, {}) or {}).items(): + edges.append(RawEdge(str(pkg), "manifest", "composer.json", None, f"{pkg}: {spec}")) + except (json.JSONDecodeError, OSError): + pass + for src in walk_files(repo_root, suffixes=(".php",), stop_at_nested_repos=True): + try: + lines = read_source_text(src, encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + rel = src.relative_to(repo_root).as_posix() + for i, line in enumerate(lines, 1): + m = _USE_STMT.match(line) + if m: + edges.append(RawEdge(m.group(1), "import", rel, i, line.strip())) + return edges diff --git a/client-plugin/server/src/index_graph/graph/resolvers/python.py b/client-plugin/server/src/index_graph/graph/resolvers/python.py new file mode 100644 index 0000000..7b1a463 --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/resolvers/python.py @@ -0,0 +1,140 @@ +"""Python ecosystem resolver: manifests + AST import scan.""" +from __future__ import annotations + +import ast +import configparser +import re +import tomllib +from pathlib import Path + +from ..walk import read_source_text, walk_files +from .base import RawEdge + +_PEP508_NAME = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)") +_MANIFESTS = ("pyproject.toml", "setup.cfg", "setup.py") + + +def _dep_name(spec: str) -> str | None: + m = _PEP508_NAME.match(spec) + return m.group(1) if m else None + + +class PythonResolver: + name = "python" + # files whose content feeds the graph (read by the freshness fingerprint) + fingerprint_names = ("pyproject.toml", "setup.cfg", "setup.py") + fingerprint_suffixes = (".py",) + fingerprint_globs = ("requirements*.txt",) + + def matches(self, repo_root: Path) -> bool: + if any((repo_root / m).is_file() for m in _MANIFESTS): + return True + return any(repo_root.glob("requirements*.txt")) + + def exposed_names(self, repo_root: Path) -> set[str]: + names: set[str] = set() + pp = repo_root / "pyproject.toml" + if pp.is_file(): + try: + data = tomllib.loads(read_source_text(pp, encoding="utf-8", errors="replace")) + proj = data.get("project", {}) + if isinstance(proj, dict) and proj.get("name"): + names.add(str(proj["name"])) + except (tomllib.TOMLDecodeError, OSError): + pass + cfg = repo_root / "setup.cfg" + if cfg.is_file(): + try: + cp = configparser.ConfigParser() + cp.read_string(read_source_text(cfg, encoding="utf-8", errors="strict")) + if cp.has_option("metadata", "name"): + names.add(cp.get("metadata", "name")) + except (configparser.Error, OSError): + pass + # top-level importable packages/modules (repo root and src/) + for base in (repo_root, repo_root / "src"): + if not base.is_dir(): + continue + for child in base.iterdir(): + if child.is_dir() and (child / "__init__.py").is_file(): + names.add(child.name) + elif child.suffix == ".py" and child.stem not in {"setup", "conftest"}: + names.add(child.stem) + return names + + def raw_edges(self, repo_root: Path) -> list[RawEdge]: + edges: list[RawEdge] = [] + edges += self._manifest_edges(repo_root) + edges += self._import_edges(repo_root) + return edges + + def _manifest_edges(self, repo_root: Path) -> list[RawEdge]: + out: list[RawEdge] = [] + pp = repo_root / "pyproject.toml" + if pp.is_file(): + try: + text = read_source_text(pp, encoding="utf-8", errors="replace") + data = tomllib.loads(text) + proj = data.get("project", {}) + deps = list(proj.get("dependencies", []) or []) + for group in (proj.get("optional-dependencies", {}) or {}).values(): + deps += list(group or []) + for spec in deps: + name = _dep_name(str(spec)) + if name: + line = _pyproject_dep_line(text, str(spec)) + out.append(RawEdge(name, "manifest", "pyproject.toml", line, str(spec))) + except (tomllib.TOMLDecodeError, OSError): + pass + for req in sorted(repo_root.glob("requirements*.txt")): + try: + for i, line in enumerate(read_source_text(req, encoding="utf-8", errors="replace").splitlines(), 1): + s = line.strip() + if not s or s.startswith(("#", "-")): + continue + name = _dep_name(s) + if name: + out.append(RawEdge(name, "manifest", req.name, i, s)) + except OSError: + pass + return out + + def _import_edges(self, repo_root: Path) -> list[RawEdge]: + out: list[RawEdge] = [] + for py in walk_files(repo_root, suffixes=(".py",), stop_at_nested_repos=True): + try: + tree = ast.parse(read_source_text(py, encoding="utf-8", errors="replace")) + except (OSError, SyntaxError, ValueError): + continue + rel = py.relative_to(repo_root).as_posix() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for a in node.names: + top = a.name.split(".")[0] + out.append(RawEdge(top, "import", rel, node.lineno, f"import {a.name}")) + elif isinstance(node, ast.ImportFrom): + if node.level == 0 and node.module: + top = node.module.split(".")[0] + out.append(RawEdge(top, "import", rel, node.lineno, f"from {node.module} import ...")) + return out + + +def _pyproject_dep_line(text: str, spec: str) -> int | None: + variants = (f'"{spec}"', f"'{spec}'") + in_optional = False + in_array = False + for i, line in enumerate(text.splitlines(), 1): + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + in_optional = stripped == "[project.optional-dependencies]" + in_array = False + starts_deps = stripped.startswith("dependencies") + starts_optional_group = in_optional and "=" in stripped and not stripped.startswith("#") + candidate = starts_deps or starts_optional_group or in_array + if candidate and any(v in line for v in variants): + return i + if starts_deps or starts_optional_group: + in_array = "[" in stripped and "]" not in stripped + elif in_array and "]" in stripped: + in_array = False + return None diff --git a/client-plugin/server/src/index_graph/graph/resolvers/ruby.py b/client-plugin/server/src/index_graph/graph/resolvers/ruby.py new file mode 100644 index 0000000..ac76cb7 --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/resolvers/ruby.py @@ -0,0 +1,70 @@ +"""Ruby ecosystem resolver: Gemfile gem declarations + require/require_relative scan.""" +from __future__ import annotations + +import re +from pathlib import Path + +from ..walk import read_source_text, walk_files +from .base import RawEdge + +_GEM_LINE = re.compile(r"""^\s*gem\s+['"]([^'"]+)['"]""") +_REQUIRE = re.compile(r"""^\s*require\s+['"]([^'"]+)['"]""") +_REQUIRE_RELATIVE = re.compile(r"""^\s*require_relative\s+['"]([^'"]+)['"]""") +_SPEC_NAME = re.compile(r"""(?:spec|s)\.name\s*=\s*['"]([^'"]+)['"]""") + + +class RubyResolver: + name = "ruby" + # files whose content feeds the graph (read by the freshness fingerprint) + fingerprint_names = ("Gemfile",) + fingerprint_suffixes = (".rb", ".gemspec") + + def matches(self, repo_root: Path) -> bool: + if (repo_root / "Gemfile").is_file(): + return True + return any(repo_root.glob("*.gemspec")) + + def exposed_names(self, repo_root: Path) -> set[str]: + for gemspec in repo_root.glob("*.gemspec"): + try: + text = read_source_text(gemspec, encoding="utf-8", errors="replace") + except OSError: + continue + m = _SPEC_NAME.search(text) + if m: + return {m.group(1)} + return {repo_root.name} + + def raw_edges(self, repo_root: Path) -> list[RawEdge]: + edges: list[RawEdge] = [] + + # manifest edges: Gemfile + gemfile = repo_root / "Gemfile" + if gemfile.is_file(): + try: + lines = read_source_text(gemfile, encoding="utf-8", errors="replace").splitlines() + except OSError: + lines = [] + for i, line in enumerate(lines, 1): + m = _GEM_LINE.match(line) + if m: + gem = m.group(1) + edges.append(RawEdge(gem, "manifest", "Gemfile", i, line.strip())) + + # import edges: .rb source files + for src in walk_files(repo_root, suffixes=(".rb",), stop_at_nested_repos=True): + try: + lines = read_source_text(src, encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + rel = src.relative_to(repo_root).as_posix() + for i, line in enumerate(lines, 1): + m = _REQUIRE.match(line) + if m: + edges.append(RawEdge(m.group(1), "import", rel, i, line.strip())) + continue + m = _REQUIRE_RELATIVE.match(line) + if m: + edges.append(RawEdge(m.group(1), "import", rel, i, line.strip())) + + return edges diff --git a/client-plugin/server/src/index_graph/graph/resolvers/rust.py b/client-plugin/server/src/index_graph/graph/resolvers/rust.py new file mode 100644 index 0000000..cb32fa9 --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/resolvers/rust.py @@ -0,0 +1,65 @@ +"""Rust ecosystem resolver: Cargo.toml manifests + a use/extern-crate scan.""" +from __future__ import annotations + +import re +import tomllib +from collections.abc import Iterator +from pathlib import Path + +from ..walk import read_source_text, walk_files +from .base import RawEdge + +_DEP_TABLES = ("dependencies", "dev-dependencies", "build-dependencies") +_USE = re.compile(r"^\s*use\s+([A-Za-z_][A-Za-z0-9_]*)") +_EXTERN = re.compile(r"^\s*extern\s+crate\s+([A-Za-z_][A-Za-z0-9_]*)") +_INTRA = {"crate", "self", "super"} # path roots that name the current crate, not a dep + + +class RustResolver: + name = "rust" + # files whose content feeds the graph (read by the freshness fingerprint) + fingerprint_names = ("Cargo.toml",) + fingerprint_suffixes = (".rs",) + + def matches(self, repo_root: Path) -> bool: + return (repo_root / "Cargo.toml").is_file() + + def _manifests(self, repo_root: Path) -> Iterator[Path]: + return walk_files(repo_root, names=("Cargo.toml",), stop_at_nested_repos=True) + + def exposed_names(self, repo_root: Path) -> set[str]: + names: set[str] = set() + for ct in self._manifests(repo_root): + try: + data = tomllib.loads(read_source_text(ct, encoding="utf-8", errors="replace")) + except (tomllib.TOMLDecodeError, OSError): + continue + pkg = data.get("package", {}) + if isinstance(pkg, dict) and pkg.get("name"): + names.add(str(pkg["name"])) + return names + + def raw_edges(self, repo_root: Path) -> list[RawEdge]: + edges: list[RawEdge] = [] + for ct in self._manifests(repo_root): + try: + data = tomllib.loads(read_source_text(ct, encoding="utf-8", errors="replace")) + except (tomllib.TOMLDecodeError, OSError): + continue + rel = ct.relative_to(repo_root).as_posix() + for table in _DEP_TABLES: + section = data.get(table, {}) + if isinstance(section, dict): + for name in section: + edges.append(RawEdge(str(name), "manifest", rel, None, f"{table}.{name}")) + for src in walk_files(repo_root, suffixes=(".rs",), stop_at_nested_repos=True): + try: + lines = read_source_text(src, encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + rel = src.relative_to(repo_root).as_posix() + for i, line in enumerate(lines, 1): + m = _USE.match(line) or _EXTERN.match(line) + if m and m.group(1) not in _INTRA: + edges.append(RawEdge(m.group(1), "import", rel, i, line.strip())) + return edges diff --git a/client-plugin/server/src/index_graph/graph/roles.py b/client-plugin/server/src/index_graph/graph/roles.py new file mode 100644 index 0000000..1eaa622 --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/roles.py @@ -0,0 +1,65 @@ +"""Topology-derived structural roles + salience faithfulness (ported).""" +from __future__ import annotations + +from .edges import Edge + + +def structural_salience(edges: list[Edge]) -> dict[str, dict]: + indeg: dict[str, int] = {} + outdeg: dict[str, int] = {} + for e in edges: + if e.external or e.to_repo is None: + continue + outdeg[e.from_repo] = outdeg.get(e.from_repo, 0) + 1 + indeg[e.to_repo] = indeg.get(e.to_repo, 0) + 1 + nodes = set(indeg) | set(outdeg) + max_in = max(indeg.values(), default=0) + out: dict[str, dict] = {} + for n in sorted(nodes): + i, o = indeg.get(n, 0), outdeg.get(n, 0) + out[n] = {"in_degree": i, "out_degree": o, "hub": i == max_in and i >= 2} + return out + + +def derive_roles(repo_names: set[str], edges: list[Edge], + markers: dict[str, set[str]]) -> dict[str, tuple[str, ...]]: + sal = structural_salience(edges) + max_out = max((s["out_degree"] for s in sal.values()), default=0) + roles: dict[str, list[str]] = {} + for name in sorted(repo_names): + s = sal.get(name, {"in_degree": 0, "out_degree": 0, "hub": False}) + mk = markers.get(name, set()) + rs: list[str] = [] + if "entry" in mk: + rs.append("entrypoint") + if "published" in mk and s["in_degree"] >= 1 and "entry" not in mk: + rs.append("library") + if s["hub"]: + rs.append("hub") + if s["out_degree"] == max_out and s["out_degree"] >= 3: + rs.append("orchestrator") + if s["in_degree"] == 0 and s["out_degree"] == 0 and name in markers: + rs.append("leaf") + if name not in markers: + rs.append("isolated") + roles[name] = tuple(rs) + return roles + + +def salience_audit(salience: dict[str, dict], marked: dict[str, list[str]]) -> list[dict]: + hubs = sorted(n for n, s in salience.items() if s["hub"]) + warns: list[dict] = [] + for name, mk in sorted(marked.items()): + if name not in hubs: + warns.append({"kind": "decorative-non-hub", "node": name, "markers": mk, + "in_degree": salience.get(name, {}).get("in_degree", 0), + "hubs": hubs, + "note": "marked node is not the structural hub; a render must not let " + "its marker outshine the hub(s)"}) + for h in hubs: + if h not in marked: + warns.append({"kind": "unmarked-hub", "node": h, + "in_degree": salience.get(h, {}).get("in_degree", 0), + "note": "structural convergence hub carries no marker; a faithful render " + "should make it central"}) + return warns diff --git a/client-plugin/server/src/index_graph/graph/walk.py b/client-plugin/server/src/index_graph/graph/walk.py new file mode 100644 index 0000000..7b22f08 --- /dev/null +++ b/client-plugin/server/src/index_graph/graph/walk.py @@ -0,0 +1,317 @@ +"""Pruned filesystem walks; complete graph scopes fail on source I/O errors.""" +from __future__ import annotations + +import fnmatch +import hashlib +import io +import os +import threading +from contextlib import contextmanager +from collections.abc import Iterator, Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path + +EXCLUDE_DIRS = frozenset({ + ".git", ".hg", ".svn", ".venv", "venv", "env", + "venvs", "node_modules", "site-packages", "lib64", "__pycache__", + ".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache", + "build", "dist", ".eggs", ".cache", ".playwright-mcp", + ".warden-safe-cache", ".next", ".turbo", + "target", "coverage", ".coverage", ".nyc_output", + ".parcel-cache", ".svelte-kit", ".angular", ".expo", + ".gradle", ".idea", ".vscode", ".yarn", ".pnpm-store", + ".terraform", "out", +}) + +_LOCAL = threading.local() +_SOURCE_CACHE_MAX_BYTES = 16 * 1024 * 1024 +_SOURCE_CACHE_MAX_FILES = 4096 +_SOURCE_DIGEST_JOURNAL_MAX_ENTRIES = 100_000 + + +class GraphSourceError(RuntimeError): + """A complete graph cannot be derived because a source could not be read.""" + + +@dataclass(frozen=True) +class DirectoryMembershipSnapshot: + path: Path + entries: tuple[tuple[str, str], ...] + + +@dataclass(frozen=True) +class PreloadedFileListing: + files: tuple[Path, ...] + directory_snapshots: tuple[DirectoryMembershipSnapshot, ...] = () + captured_root: Path | None = None + + +PreloadedFileValue = Sequence[Path | str] | PreloadedFileListing + + +def _source_error(operation: str, path: Path, error: OSError) -> None: + if getattr(_LOCAL, "file_cache", None) is not None: + _LOCAL.source_reuse_complete = False + # Do not copy raw OS diagnostics or absolute private paths into receipts. + raise GraphSourceError( + f"graph source {operation} failed: {path.name!r} ({type(error).__name__})" + ) from None + + +def _walk_error(error: OSError) -> None: + _source_error("traversal", Path(error.filename) if error.filename else Path("source"), error) + + +def _directory_entry_kind(entry: os.DirEntry) -> str: + try: + if entry.is_dir(follow_symlinks=False): + return "dir" + if entry.is_file(follow_symlinks=False): + return "file" + except OSError: + return "unreadable" + return "other" + + +def directory_membership_from_walk( + path: Path, + dirnames: Sequence[str], + filenames: Sequence[str], +) -> DirectoryMembershipSnapshot: + entries = [(name, "dir") for name in dirnames] + entries.extend((name, "file") for name in filenames) + return DirectoryMembershipSnapshot( + path=Path(path).resolve(), + entries=tuple(sorted(entries, key=lambda item: (item[0].lower(), item[0], item[1]))), + ) + + +def _scan_directory_membership(path: Path) -> tuple[tuple[str, str], ...]: + entries: list[tuple[str, str]] = [] + with os.scandir(path) as scanner: + for entry in scanner: + entries.append((entry.name, _directory_entry_kind(entry))) + return tuple(sorted(entries, key=lambda item: (item[0].lower(), item[0], item[1]))) + + +def _preloaded_is_current(listing: PreloadedFileListing) -> bool: + for snapshot in listing.directory_snapshots: + try: + current = _scan_directory_membership(snapshot.path) + except OSError: + return False + if current != snapshot.entries: + return False + return True + + +def _preloaded_file_listing(value: PreloadedFileValue) -> PreloadedFileListing: + if isinstance(value, PreloadedFileListing): + return value + return PreloadedFileListing(tuple(Path(path) for path in value)) + + +def _preloaded_file_cache( + file_lists: Mapping[Path | str, PreloadedFileValue] | None, +) -> dict[str, PreloadedFileListing]: + if not file_lists: + return {} + return { + str(Path(root).resolve()): _preloaded_file_listing(paths) + for root, paths in file_lists.items() + } + + +@contextmanager +def cached_file_scope(file_lists: Mapping[Path | str, PreloadedFileValue] | None = None): + """Share one pruned filesystem listing across resolver walks in one repo build.""" + previous = getattr(_LOCAL, "file_cache", None) + preloaded = _preloaded_file_cache(file_lists) + if previous is None: + _LOCAL.file_cache = dict(preloaded) + _LOCAL.source_cache = {} + _LOCAL.source_digest_journal = {} + _LOCAL.source_cache_bytes = 0 + _LOCAL.source_reuse_complete = True + elif preloaded: + _LOCAL.file_cache = {**previous, **preloaded} + try: + yield + finally: + if previous is None: + try: + delattr(_LOCAL, "file_cache") + delattr(_LOCAL, "source_cache") + delattr(_LOCAL, "source_digest_journal") + delattr(_LOCAL, "source_cache_bytes") + delattr(_LOCAL, "source_reuse_complete") + except AttributeError: + pass + else: + _LOCAL.file_cache = previous + + +def _record_unretained_source_read(key: str, data: bytes) -> None: + """Track consistency for source bytes that are too large to retain. + + The journal holds path digests, not file bodies. It is still bounded because + very large repositories can exceed byte and file retention at the same time. + If the journal bound is exceeded, persistent cache is disabled for the build + while source coverage continues from the bytes that were read. + """ + journal = getattr(_LOCAL, "source_digest_journal", None) + if journal is None: + _LOCAL.source_reuse_complete = False + return + digest = hashlib.sha256(data).hexdigest() + previous = journal.get(key) + if previous is None: + if len(journal) >= _SOURCE_DIGEST_JOURNAL_MAX_ENTRIES: + _LOCAL.source_reuse_complete = False + return + journal[key] = digest + elif previous != digest: + _LOCAL.source_reuse_complete = False + + +def read_source_bytes(path: Path) -> bytes: + """Reuse exact working bytes only within a build, with bounded memory. + + This never trusts Git metadata or a previous process's source bytes. Files + beyond the memory/file limit are read normally; coverage is unchanged. Their + first-read digest is journaled so repeated resolver reads can still prove + they saw the same bytes before the derived build is written to disk cache. + """ + cache = getattr(_LOCAL, "source_cache", None) + key = str(path.absolute()) + if cache is not None and key in cache: + return cache[key] + try: + data = path.read_bytes() + except OSError as error: + _source_error("read", path, error) + raise + if cache is not None: + can_retain = (len(cache) < _SOURCE_CACHE_MAX_FILES + and _LOCAL.source_cache_bytes + len(data) <= _SOURCE_CACHE_MAX_BYTES) + # Check previously journaled bytes even if this read now fits the byte + # cache. A file can shrink between fingerprinting and resolver parsing. + if key in _LOCAL.source_digest_journal or not can_retain: + _record_unretained_source_read(key, data) + if can_retain: + cache[key] = data + _LOCAL.source_cache_bytes += len(data) + return data + + +def source_reuse_complete() -> bool: + """Whether shared source reads remain consistent enough for cache persistence.""" + return getattr(_LOCAL, "source_reuse_complete", False) + + +def read_source_text(path: Path, *, encoding="utf-8", errors="replace") -> str: + # TextIOWrapper preserves Path.read_text's universal-newline behavior. + with io.TextIOWrapper(io.BytesIO(read_source_bytes(path)), + encoding=encoding, errors=errors) as stream: + return stream.read() + + +def _matches(filename: str, suffixes: tuple[str, ...] | None, + names: tuple[str, ...] | None, + globs: tuple[str, ...] | None = None) -> bool: + if names is not None and filename in names: + return True + if suffixes is not None and filename.endswith(suffixes): + return True + return globs is not None and any(fnmatch.fnmatchcase(filename, g) for g in globs) + + +def _within_captured_repo(path: Path, root: Path) -> bool: + physical = path.resolve() + if not physical.is_relative_to(root): + return False + while physical != root: + # A junction may enter below a nested repository's marker, bypassing + # the lexical walker's ordinary stop-at-.git check. + if os.path.lexists(physical / ".git"): + return False + physical = physical.parent + return True + + +def _scoped_all_files(root: Path, checkpoint=None) -> list[Path]: + """All local files under existing pruning and nested repository boundaries. + + Do not use Git ignore rules or index metadata here: ignored and index-hidden + working files can contain dependencies. Cache only inside one repo build. + """ + key = str(root.resolve()) + cache = getattr(_LOCAL, "file_cache", None) + contained_root = None + if cache is not None and key in cache: + cached = cache[key] + if isinstance(cached, PreloadedFileListing): + contained_root = cached.captured_root + if contained_root is not None and root.resolve() != contained_root: + raise GraphSourceError("repository root changed after router inventory") + if _preloaded_is_current(cached): + files = list(cached.files) + cache[key] = files + return files + del cache[key] + else: + return cached + out: list[Path] = [] + for dirpath, dirnames, filenames in os.walk(root, onerror=_walk_error): + current = Path(dirpath) + # A stale router preload must not widen its original physical scope + # when the fallback walker encounters a repository-internal junction. + if contained_root is not None and not _within_captured_repo(current, contained_root): + dirnames[:] = [] + continue + if checkpoint is not None and not checkpoint(current): + # An interrupted listing is never reusable as a complete scope. + return out + if current != root and (".git" in dirnames or ".git" in filenames): + dirnames[:] = [] + continue + dirnames[:] = sorted((d for d in dirnames if d not in EXCLUDE_DIRS), key=str.lower) + out.extend(current / filename for filename in sorted(filenames)) + if cache is not None: + cache[key] = out + return out + + +def walk_files(root: Path, suffixes: tuple[str, ...] | None = None, + names: tuple[str, ...] | None = None, + checkpoint=None, + globs: tuple[str, ...] | None = None, + stop_at_nested_repos: bool = False) -> Iterator[Path]: + """Yield files under `root`, pruning EXCLUDE_DIRS. + + Match by `suffixes` (e.g. (".py",)) or exact `names` (e.g. ("__main__.py",)). + Outside graph-build scopes, a missing/unreadable root yields nothing. Inside + a scoped graph build, traversal errors raise GraphSourceError so omitted + sources cannot become a successful complete graph or cached file listing. + + When `stop_at_nested_repos` is true, a child directory with a .git marker is + treated as a separate repository boundary. The root itself is still scanned. + """ + root = Path(root) + if stop_at_nested_repos: + for path in _scoped_all_files(root, checkpoint=checkpoint): + if checkpoint is not None and not checkpoint(path.parent): + break + if _matches(path.name, suffixes, names, globs): + yield path + return + + for dirpath, dirnames, filenames in os.walk(root, onerror=_walk_error): + current = Path(dirpath) + if checkpoint is not None and not checkpoint(current): + dirnames[:] = [] + break + dirnames[:] = sorted((d for d in dirnames if d not in EXCLUDE_DIRS), key=str.lower) + for fn in filenames: + if _matches(fn, suffixes, names, globs): + yield Path(dirpath) / fn diff --git a/client-plugin/server/src/index_graph/internals/__init__.py b/client-plugin/server/src/index_graph/internals/__init__.py new file mode 100644 index 0000000..599e1a0 --- /dev/null +++ b/client-plugin/server/src/index_graph/internals/__init__.py @@ -0,0 +1,10 @@ +"""Intra-repo module graph: see inside a repo, not only repo as atom.""" +from __future__ import annotations + +from .modules import ModuleNode, InternalEdge, Unresolved, discover_modules, extract_internal_edges +from .build import InternalGraph, Coverage, build_internals + +__all__ = [ + "ModuleNode", "InternalEdge", "Unresolved", "InternalGraph", "Coverage", + "discover_modules", "extract_internal_edges", "build_internals", +] diff --git a/client-plugin/server/src/index_graph/internals/build.py b/client-plugin/server/src/index_graph/internals/build.py new file mode 100644 index 0000000..fac29af --- /dev/null +++ b/client-plugin/server/src/index_graph/internals/build.py @@ -0,0 +1,76 @@ +"""Assemble an InternalGraph: modules, internal edges, cycles, fan-in/out, coverage.""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from ..graph.edges import Edge +from ..graph.cycles import find_cycles +from .modules import ModuleNode, InternalEdge, Unresolved, discover_modules, _extract + + +@dataclass(frozen=True) +class Coverage: + """How structurally complete the module graph is, and what it could not see. + A verdict over this graph is sound only for what coverage marks resolved + (call-graph soundness: static analysis cannot see dynamic dispatch or + unparseable files, so it must say so rather than imply completeness).""" + modules: int + internal_edges: int + parse_errors: tuple[str, ...] + dynamic_imports: tuple[tuple[str, int | None], ...] + + @property + def complete(self) -> bool: + return not self.parse_errors and not self.dynamic_imports + + +@dataclass(frozen=True) +class InternalGraph: + repo: str + modules: tuple[ModuleNode, ...] + edges: tuple[InternalEdge, ...] + cycles: tuple[tuple[str, ...], ...] + fan_in: dict[str, int] + fan_out: dict[str, int] + coverage: Coverage + + +def _cycles(edges: tuple[InternalEdge, ...]) -> tuple[tuple[str, ...], ...]: + # Reuse the repo-level Tarjan SCC by constructing minimal internal Edges; + # find_cycles reads only from_repo/to_repo/external. + as_edges = [Edge(e.from_id, e.to_id, e.to_id, False, "high", ()) for e in edges] + return tuple(find_cycles(as_edges)) + + +def _coverage(modules: tuple[ModuleNode, ...], edges: tuple[InternalEdge, ...], + unresolved: list[Unresolved]) -> Coverage: + return Coverage( + modules=len(modules), + internal_edges=len(edges), + parse_errors=tuple(sorted({u.file for u in unresolved if u.reason == "parse_error"})), + dynamic_imports=tuple(sorted( + {(u.file, u.line) for u in unresolved if u.reason == "dynamic"}, + key=lambda t: (t[0], t[1] or 0))), + ) + + +def build_internals(repo_root: Path, repo_name: str | None = None) -> InternalGraph: + root = repo_root.resolve() + name = repo_name or root.name + modules = tuple(discover_modules(root)) + edge_list, unresolved = _extract(root, list(modules)) + edges = tuple(edge_list) + fan_out: dict[str, int] = {} + fan_in: dict[str, int] = {} + seen_out: set[tuple[str, str]] = set() + seen_in: set[tuple[str, str]] = set() + for e in edges: + if (e.from_id, e.to_id) not in seen_out: + seen_out.add((e.from_id, e.to_id)) + fan_out[e.from_id] = fan_out.get(e.from_id, 0) + 1 + if (e.to_id, e.from_id) not in seen_in: + seen_in.add((e.to_id, e.from_id)) + fan_in[e.to_id] = fan_in.get(e.to_id, 0) + 1 + return InternalGraph(name, modules, edges, _cycles(edges), fan_in, fan_out, + _coverage(modules, edges, unresolved)) diff --git a/client-plugin/server/src/index_graph/internals/modules.py b/client-plugin/server/src/index_graph/internals/modules.py new file mode 100644 index 0000000..ae25826 --- /dev/null +++ b/client-plugin/server/src/index_graph/internals/modules.py @@ -0,0 +1,337 @@ +"""Module discovery and intra-repo import extraction, per language. + +Python is AST-exact. JavaScript/TypeScript, Rust, and Go are best-effort and +file-level: relative or path-aligned imports resolve to sibling modules, bare +or external specifiers are ignored. Dynamic and aliased imports may be missed. +""" +from __future__ import annotations + +import ast +import re +from dataclasses import dataclass +from pathlib import Path + +from ..graph.walk import walk_files + + +@dataclass(frozen=True) +class ModuleNode: + id: str + path: str + language: str + + +@dataclass(frozen=True) +class InternalEdge: + from_id: str + to_id: str + evidence_file: str + evidence_line: int | None + raw: str + + +@dataclass(frozen=True) +class Unresolved: + """An import the static scan could not turn into a definite internal edge. + The soundness gap a verdict must be honest about (call-graph soundness: + static analysis cannot see dynamic dispatch or unparseable files). Dynamic + detection over-approximates toward reporting (a method coincidentally named + import_module may be flagged); over-reporting unverifiability is the safe + direction for a soundness gap, never under-reporting it.""" + file: str + line: int | None + reason: str # "parse_error" | "dynamic" + raw: str + + +_LANG_BY_SUFFIX = { + ".py": "python", + ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", + ".cjs": "javascript", ".ts": "typescript", ".tsx": "typescript", + ".rs": "rust", ".go": "go", +} +_SUFFIXES = tuple(_LANG_BY_SUFFIX) + + +def _strip_suffix(rel: str) -> str: + dot = rel.rfind(".") + slash = rel.rfind("/") + return rel[:dot] if dot > slash else rel + + +def discover_modules(repo_root: Path) -> list[ModuleNode]: + mods: list[ModuleNode] = [] + for f in walk_files(repo_root, suffixes=_SUFFIXES): + rel = f.relative_to(repo_root).as_posix() + lang = _LANG_BY_SUFFIX.get(f.suffix, "") + if not lang: + continue + mods.append(ModuleNode(id=_strip_suffix(rel), path=rel, language=lang)) + return sorted(mods, key=lambda m: m.id) + + +# --- Python (AST-exact) ---------------------------------------------------- + +def _id_for(base: str, ids: set[str]) -> str | None: + """Resolve a slash-path base to an internal module id (file or package).""" + if base in ids: + return base + pkg = base + "/__init__" + return pkg if pkg in ids else None + + +def _dotted_to_id(dotted: str, ids: set[str]) -> str | None: + return _id_for(dotted.replace(".", "/"), ids) + + +def _pkg_depth(repo_root: Path, importer_id: str) -> int: + """How many package levels a relative import may legally walk up from this + module: the run of __init__.py-bearing directories from the module's own + directory up to and including the repo root. A `from .x` needs depth >= 1, + `from ..x` needs depth >= 2, and so on. This makes resolution correct for + both a src-layout single package (the root is itself a package) and a + workspace of separate packages (the root is not).""" + parts = importer_id.split("/")[:-1] + depth = 0 + while True: + d = repo_root.joinpath(*parts) if parts else repo_root + if (d / "__init__.py").is_file(): + depth += 1 + if not parts: + break + parts = parts[:-1] + else: + break + return depth + + +def _resolve_relative(importer_id: str, level: int, module: str | None, + ids: set[str], depth: int) -> str | None: + if level > depth: + return None # walks above the top-level package: not an internal import + pkg_parts = importer_id.split("/")[:-1] + base = pkg_parts[:len(pkg_parts) - (level - 1)] + target = base + (module.split(".") if module else []) + if not target: + return None + return _id_for("/".join(target), ids) + + +def _python_edges(repo_root: Path, ids: set[str]) -> tuple[list[InternalEdge], list[Unresolved]]: + out: list[InternalEdge] = [] + unresolved: list[Unresolved] = [] + for py in walk_files(repo_root, suffixes=(".py",)): + rel = py.relative_to(repo_root).as_posix() + from_id = _strip_suffix(rel) + try: + tree = ast.parse(py.read_text(encoding="utf-8-sig")) + except OSError: + continue + except (SyntaxError, ValueError): + unresolved.append(Unresolved(rel, None, "parse_error", "")) + continue + depth = _pkg_depth(repo_root, from_id) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for a in node.names: + tid = _dotted_to_id(a.name, ids) + if tid and tid != from_id: + out.append(InternalEdge(from_id, tid, rel, node.lineno, f"import {a.name}")) + elif isinstance(node, ast.ImportFrom): + if node.level and node.module is None: + if node.level <= depth: + pkg_parts = from_id.split("/")[:-1] + base = pkg_parts[:len(pkg_parts) - (node.level - 1)] + for a in node.names: + tid = _id_for("/".join([*base, a.name]), ids) + if tid and tid != from_id: + out.append(InternalEdge( + from_id, tid, rel, node.lineno, + f"from {'.' * node.level} import {a.name}")) + continue + if node.level and node.level > 0: + tid = _resolve_relative(from_id, node.level, node.module, ids, depth) + raw = f"from {'.' * node.level}{node.module or ''} import ..." + elif node.module: + tid = _dotted_to_id(node.module, ids) + raw = f"from {node.module} import ..." + else: + tid = None + raw = "" + if tid and tid != from_id: + out.append(InternalEdge(from_id, tid, rel, node.lineno, raw)) + elif isinstance(node, ast.Call): + fn = node.func + if (isinstance(fn, ast.Name) and fn.id == "__import__") or \ + (isinstance(fn, ast.Attribute) and fn.attr in ("import_module", "__import__")): + unresolved.append(Unresolved(rel, node.lineno, "dynamic", "dynamic import")) + return out, unresolved + + +# --- JavaScript / TypeScript (best-effort, relative specifiers) ------------ + +_JS_IMPORT = re.compile( + r"""(?:import|export)[^'"]*?from\s*['"]([^'"]+)['"]""" + r"""|require\(\s*['"]([^'"]+)['"]\s*\)""" + r"""|import\(\s*['"]([^'"]+)['"]\s*\)""") +_JS_DYNAMIC = re.compile(r"""(? str | None: + if not spec.startswith("."): + return None + base = (Path(importer_rel).parent / spec).as_posix() + parts: list[str] = [] + for seg in base.split("/"): + if seg in ("", "."): + continue + if seg == "..": + if not parts: + return None # the specifier escapes above the repo root + parts.pop() + continue + parts.append(seg) + cand = "/".join(parts) + if "." in Path(cand).name: + cand = _strip_suffix(cand) + if cand in ids: + return cand + idx = cand + "/index" + return idx if idx in ids else None + + +def _js_edges(repo_root: Path, ids: set[str]) -> tuple[list[InternalEdge], list[Unresolved]]: + out: list[InternalEdge] = [] + unresolved: list[Unresolved] = [] + for f in walk_files(repo_root, suffixes=_JS_SUFFIXES): + rel = f.relative_to(repo_root).as_posix() + from_id = _strip_suffix(rel) + try: + text = f.read_text(encoding="utf-8-sig") + except OSError: + continue + for i, line in enumerate(text.splitlines(), 1): + for m in _JS_IMPORT.finditer(line): + spec = m.group(1) or m.group(2) or m.group(3) + if not spec: + continue + tid = _js_resolve(rel, spec, ids) + if tid and tid != from_id: + out.append(InternalEdge(from_id, tid, rel, i, line.strip())) + if _JS_DYNAMIC.search(line): + unresolved.append(Unresolved(rel, i, "dynamic", line.strip())) + return out, unresolved + + +# --- Rust (best-effort, mod declarations) ---------------------------------- + +_RUST_MOD = re.compile(r"^\s*(?:pub\s+)?mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*;") + + +def _rust_edges(repo_root: Path, ids: set[str]) -> tuple[list[InternalEdge], list[Unresolved]]: + out: list[InternalEdge] = [] + for f in walk_files(repo_root, suffixes=(".rs",)): + rel = f.relative_to(repo_root).as_posix() + from_id = _strip_suffix(rel) + parent = Path(rel).parent.as_posix() + parent = "" if parent == "." else parent + "/" + try: + text = f.read_text(encoding="utf-8-sig") + except OSError: + continue + for i, line in enumerate(text.splitlines(), 1): + m = _RUST_MOD.match(line) + if not m: + continue + name = m.group(1) + for cand in (f"{parent}{name}", f"{parent}{name}/mod", f"{from_id}/{name}"): + if cand in ids and cand != from_id: + out.append(InternalEdge(from_id, cand, rel, i, line.strip())) + break + return out, [] + + +# --- Go (best-effort, internal package imports) ---------------------------- + +_GO_MODULE = re.compile(r"^\s*module\s+(\S+)") +_GO_IMPORT_SINGLE = re.compile(r'^\s*import\s+"([^"]+)"') +_GO_IMPORT_BLOCK_LINE = re.compile(r'^\s*"([^"]+)"') + + +def _go_module_path(repo_root: Path) -> str | None: + gomod = repo_root / "go.mod" + if not gomod.is_file(): + return None + try: + for line in gomod.read_text(encoding="utf-8-sig").splitlines(): + m = _GO_MODULE.match(line) + if m: + return m.group(1) + except OSError: + return None + return None + + +def _go_edges(repo_root: Path, ids: set[str]) -> tuple[list[InternalEdge], list[Unresolved]]: + mod_path = _go_module_path(repo_root) + if not mod_path: + return [], [] + pkg_dirs = {Path(m).parent.as_posix() for m in ids} + out: list[InternalEdge] = [] + for f in walk_files(repo_root, suffixes=(".go",)): + rel = f.relative_to(repo_root).as_posix() + from_id = _strip_suffix(rel) + try: + lines = f.read_text(encoding="utf-8-sig").splitlines() + except OSError: + continue + in_block = False + for i, line in enumerate(lines, 1): + spec = None + if line.strip().startswith("import ("): + in_block = True + continue + if in_block: + if ")" in line: + in_block = False + m = _GO_IMPORT_BLOCK_LINE.match(line) + if m: + spec = m.group(1) + else: + m = _GO_IMPORT_SINGLE.match(line) + if m: + spec = m.group(1) + if spec and spec.startswith(mod_path + "/"): + sub = spec[len(mod_path) + 1:] + if sub and sub in pkg_dirs: + target = next((m2 for m2 in sorted(ids) + if Path(m2).parent.as_posix() == sub), None) + if target and target != from_id: + out.append(InternalEdge(from_id, target, rel, i, line.strip())) + return out, [] + + +# --- Dispatch -------------------------------------------------------------- + +def _extract(repo_root: Path, modules: list[ModuleNode]) -> tuple[list[InternalEdge], list[Unresolved]]: + by_lang: dict[str, set[str]] = {} + for m in modules: + by_lang.setdefault(m.language, set()).add(m.id) + js_ids = by_lang.get("javascript", set()) | by_lang.get("typescript", set()) + edges: list[InternalEdge] = [] + unresolved: list[Unresolved] = [] + for fn, lang_ids in ((_python_edges, by_lang.get("python", set())), + (_js_edges, js_ids), + (_rust_edges, by_lang.get("rust", set())), + (_go_edges, by_lang.get("go", set()))): + e, u = fn(repo_root, lang_ids) + edges += e + unresolved += u + edges.sort(key=lambda x: (x.from_id, x.to_id, x.evidence_file, x.evidence_line or 0)) + unresolved.sort(key=lambda x: (x.file, x.line or 0, x.reason)) + return edges, unresolved + + +def extract_internal_edges(repo_root: Path, modules: list[ModuleNode]) -> list[InternalEdge]: + return _extract(repo_root, modules)[0] diff --git a/client-plugin/server/src/index_graph/interop.py b/client-plugin/server/src/index_graph/interop.py new file mode 100644 index 0000000..bfa4f7c --- /dev/null +++ b/client-plugin/server/src/index_graph/interop.py @@ -0,0 +1,90 @@ +"""Interop: index's context envelopes as organ-bundle interchange entries. + +The organ bundle is the shared spine gather, crucible, forum, learn, and index +compose on. This module maps index's context envelopes (the budgeted, +receipt-backed context packs) into that entry shape, so an index envelope can +feed the agent loop, compose into forum's routing context, or back a learn +lesson through the shared spine. + +Entry shape matches the proof-surface organ-bundle contract +(entry_id, organ_id, receipt_kind, status, payload_sha256, summary, payload_ref). +gather/src/gather/interop.py is the reference implementation. +""" +from __future__ import annotations + +import hashlib +import re + +ORGAN = "index" +SPINE_KIND = "index-context-envelope" +STATUSES = frozenset({ + "pass", "fail", "unverified", "warn", "needs-human", "not-applicable", "unknown", +}) +_HEX = re.compile(r"^[0-9a-f]{64}$") +_FIELDS = ("entry_id", "organ_id", "receipt_kind", "status", "payload_sha256", + "summary", "payload_ref") + + +def _entry(entry_id: str, status: str, payload_sha256: str, summary: str, ref: str) -> dict: + return { + "entry_id": entry_id, + "organ_id": ORGAN, + "receipt_kind": SPINE_KIND, + "status": status, + "payload_sha256": payload_sha256, + "summary": summary[:160], + "payload_ref": ref, + } + + +def envelope_entry(envelope: dict, *, entry_id: str = "index-envelope-1", + ref: str = "index://context-envelope") -> dict: + """Map an index context-envelope output into an organ-bundle entry. + + The envelope is a JSON dict (schema project-telos.context-envelope/v1) + produced by `index context --json` or the `index.context.envelope` MCP tool. + """ + verification = envelope.get("verification_verdict", "UNVERIFIABLE") + selection = envelope.get("selection", {}) + mode = selection.get("mode", "?") if isinstance(selection, dict) else "?" + retained = selection.get("retained_names", []) if isinstance(selection, dict) else [] + repo_count = len(retained) + + status = "pass" if verification == "MATCH" else "warn" if verification else "unverified" + + # Use the envelope's own hash if present, else hash the canonical form + sha = envelope.get("envelope_sha256", "") + if not sha or not _HEX.match(sha): + import json + canonical = json.dumps(envelope, sort_keys=True, separators=(",", ":")) + sha = hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + summary = f"context-envelope: {repo_count} repos, mode={mode}, verdict={verification}" + return _entry(entry_id, status, sha, summary, ref) + + +def map_entry(repo: str, *, n_repos: int = 1, map_sha: str = "", + entry_id: str = "index-map-1", + ref: str = "index://map") -> dict: + """Map an index map result into an organ-bundle entry.""" + if not map_sha: + map_sha = hashlib.sha256(repo.encode("utf-8")).hexdigest() + summary = f"workspace map: {repo} ({n_repos} repos)" + return _entry(entry_id, "pass", map_sha, summary, ref) + + +def validate_entry(entry: dict) -> bool: + """Validate one organ-bundle entry shape. Returns True if well-formed.""" + if not isinstance(entry, dict): + return False + if set(entry.keys()) != set(_FIELDS): + return False + if entry["organ_id"] != ORGAN: + return False + if entry["receipt_kind"] != SPINE_KIND: + return False + if entry["status"] not in STATUSES: + return False + if not _HEX.match(entry.get("payload_sha256", "")): + return False + return True diff --git a/client-plugin/server/src/index_graph/knowledge/__init__.py b/client-plugin/server/src/index_graph/knowledge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client-plugin/server/src/index_graph/knowledge/atlas.py b/client-plugin/server/src/index_graph/knowledge/atlas.py new file mode 100644 index 0000000..68c0334 --- /dev/null +++ b/client-plugin/server/src/index_graph/knowledge/atlas.py @@ -0,0 +1,168 @@ +"""Assemble repos (index) + docs into the two-layer atlas pack.""" +from __future__ import annotations + +import re +from pathlib import Path + +from ..context.pack import to_json +from ..graph.build import DependencyGraph +from .docs import Doc, _norm # reuse the SAME normalizer that built link_targets + +_EDGE_SORT = lambda e: (e["from"], e["type"], e["to_kind"], e["to"]) +_TOKEN = re.compile(r"[0-9a-z]+") + + +def _target_index(repo_names, docs): + """normalized name -> (to_kind, id); repos win over docs on collision; first doc wins. + + Uses `_norm` (space/underscore -> dash, lowercased), the SAME normalization + docs.py applied to `[[link]]` targets, so multi-word links like [[Auth Design]] + resolve to a doc titled "Auth Design".""" + idx: dict[str, tuple[str, str]] = {} + for r in sorted(repo_names): + idx.setdefault(_norm(r), ("repo", r)) + for d in docs: + for cand in (d.title, Path(d.rel_path).stem): + idx.setdefault(_norm(cand), ("doc", d.rel_path)) + return idx + + +def _describes(doc: Doc, repo_dirs: dict[str, str]) -> str | None: + """The most-specific repo whose dir contains the doc's dir (by location), else None. + + A repo dir "" (repo at the workspace root) matches only a root-level doc + (dir_rel == ""), never docs in subdirs, since the prefix branch requires + a non-empty rdir.""" + best, best_len = None, -1 + for repo, rdir in repo_dirs.items(): + if doc.dir_rel == rdir or (rdir != "" and doc.dir_rel.startswith(rdir + "/")): + if len(rdir) > best_len: + best, best_len = repo, len(rdir) + return best + + +def _repo_dirs_by_location(repo_dirs: dict[str, str]) -> dict[str, str]: + """repo dir -> first repo name for current longest-prefix describes semantics.""" + by_dir: dict[str, str] = {} + for repo, rdir in repo_dirs.items(): + by_dir.setdefault(rdir, repo) + return by_dir + + +def _describes_indexed(doc: Doc, repo_by_dir: dict[str, str]) -> str | None: + if doc.dir_rel == "": + return repo_by_dir.get("") + current = doc.dir_rel + while True: + repo = repo_by_dir.get(current) + if repo is not None: + return repo + parent, sep, _name = current.rpartition("/") + if not sep: + return None + current = parent + + +def _mentions_name(body: str, name: str) -> bool: + # case-insensitive whole-token match; treat '-'/'_' and spaces as separators + pattern = r"(? frozenset[str]: + return frozenset(_TOKEN.findall(text.lower())) + + +def _doc_rows(docs: list[Doc]) -> list[dict]: + return [{"id": d.rel_path, "title": d.title, "dir": d.dir_rel} for d in docs] + + +def _describes_edges_indexed(docs: list[Doc], repo_dirs: dict[str, str]) -> list[dict]: + edges: list[dict] = [] + seen: set[tuple[str, str, str]] = set() + repo_by_dir = _repo_dirs_by_location(repo_dirs) + for d in docs: + repo = _describes_indexed(d, repo_by_dir) + if repo is None: + continue + key = (d.rel_path, "repo", repo) + if key not in seen: + seen.add(key) + edges.append({ + "type": "describes", + "from": d.rel_path, + "to": repo, + "to_kind": "repo", + }) + return sorted(edges, key=_EDGE_SORT) + + +def _describes_edges(docs: list[Doc], repo_dirs: dict[str, str]) -> list[dict]: + return _describes_edges_indexed(docs, repo_dirs) + + +def build_router_pack(graph: DependencyGraph, docs: list[Doc], + repo_dirs: dict[str, str]) -> dict: + pack = to_json(graph) + pack["docs"] = _doc_rows(docs) + pack["knowledge_edges"] = _describes_edges(docs, repo_dirs) + pack["knowledge_warnings"] = [] + pack["repo_dirs"] = dict(repo_dirs) + return pack + + +def build_atlas_pack(graph: DependencyGraph, docs: list[Doc], + repo_dirs: dict[str, str]) -> dict: + pack = to_json(graph) + repo_names = {n["name"] for n in pack["repos"]} + idx = _target_index(repo_names, docs) + + pack["docs"] = _doc_rows(docs) + edges: list[dict] = [] + warnings: list[str] = [] + seen: set[tuple[str, str, str]] = set() # (from, to_kind, to), strongest wins + + def add(etype: str, frm: str, to_kind: str, to: str) -> None: + key = (frm, to_kind, to) + if to is None or key in seen: + return + seen.add(key) + edges.append({"type": etype, "from": frm, "to": to, "to_kind": to_kind}) + + # describes (by location): strongest + for edge in _describes_edges(docs, repo_dirs): + add("describes", edge["from"], edge["to_kind"], edge["to"]) + # links-to (from [[wiki-links]]) + for d in docs: + for t in d.link_targets: + hit = idx.get(t) + if hit is None: + warnings.append(f"{d.rel_path}: unresolved [[{t}]]") + continue + to_kind, to = hit + if to_kind == "doc" and to == d.rel_path: + continue # self-link + add("links-to", d.rel_path, to_kind, to) + + # mentions (prose name-drops): weakest; deduped via `seen` against describes/links-to + name_of = {("repo", r): r for r in repo_names} + name_of.update({("doc", d.rel_path): d.title for d in docs}) + mention_targets = [ + (target, display, _mention_tokens(display)) + for target, display in sorted(name_of.items()) + ] + for d in docs: + body_tokens = _mention_tokens(d.body) + for (to_kind, to), display, display_tokens in mention_targets: + if (d.rel_path, to_kind, to) in seen: # already a stronger edge + continue + if to_kind == "doc" and to == d.rel_path: + continue + if display_tokens and not display_tokens.issubset(body_tokens): + continue + if _mentions_name(d.body, display): # display = repo name or doc title + add("mentions", d.rel_path, to_kind, to) + + pack["knowledge_edges"] = sorted(edges, key=_EDGE_SORT) + pack["knowledge_warnings"] = warnings + return pack diff --git a/client-plugin/server/src/index_graph/knowledge/docs.py b/client-plugin/server/src/index_graph/knowledge/docs.py new file mode 100644 index 0000000..2cc96e8 --- /dev/null +++ b/client-plugin/server/src/index_graph/knowledge/docs.py @@ -0,0 +1,92 @@ +"""Discover + parse workspace markdown into Doc nodes for the atlas.""" +from __future__ import annotations + +import re +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from time import perf_counter + +from ..graph.walk import walk_files + +_MD_SUFFIXES = (".md", ".markdown") +_H1 = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE) +# [[target]] or [[target|alias]]: capture target only +_WIKILINK = re.compile(r"\[\[\s*([^\]|]+?)\s*(?:\|[^\]]*)?\]\]") + + +def _norm(s: str) -> str: + return s.strip().lower().replace("_", "-").replace(" ", "-") + + +@dataclass(frozen=True) +class Doc: + rel_path: str # workspace-relative, forward-slashed (stable id) + title: str # first H1, else filename stem + body: str # raw markdown + link_targets: tuple[str, ...] # normalized [[wiki-link]] targets, sorted-unique + dir_rel: str # workspace-relative dir ("" at root) + + +def _parse_doc(rel_path: str, text: str) -> Doc: + m = _H1.search(text) + title = m.group(1).strip() if m else Path(rel_path).stem + targets = tuple(sorted({_norm(t) for t in _WIKILINK.findall(text)})) + parent = Path(rel_path).parent.as_posix() + return Doc(rel_path, title, text, targets, "" if parent == "." else parent) + + +def discover_docs(root: Path) -> list[Doc]: + """All markdown under `root` (pruned dirs excluded), as Docs sorted by rel_path.""" + out: list[Doc] = [] + for p in walk_files(root, suffixes=_MD_SUFFIXES): + try: + text = p.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + out.append(_parse_doc(p.relative_to(root).as_posix(), text)) + out.sort(key=lambda d: d.rel_path) + return out + + +def discover_router_docs( + root: Path, + *, + paths: Iterable[Path] | None = None, + stats: dict[str, int] | None = None, +) -> list[Doc]: + """Markdown locations for router `describes` edges, without reading bodies. + + The router only needs each doc path and containing directory to connect docs + to the most-specific repo. Atlas/workbench/wiki still call `discover_docs` + when titles, wikilinks, or prose mentions are needed. + """ + root = Path(root) + out: list[Doc] = [] + started = perf_counter() + if paths is None: + doc_paths = list(walk_files(root, suffixes=_MD_SUFFIXES)) + if stats is not None: + stats["traversal_ms"] = int((perf_counter() - started) * 1000) + stats["physical_walks"] = 1 + else: + doc_paths = list(paths) + if stats is not None: + stats["traversal_ms"] = 0 + stats["physical_walks"] = 0 + started = perf_counter() + for p in doc_paths: + rel_path = p.relative_to(root).as_posix() + parent = Path(rel_path).parent.as_posix() + out.append(Doc( + rel_path=rel_path, + title=Path(rel_path).stem, + body="", + link_targets=(), + dir_rel="" if parent == "." else parent, + )) + out.sort(key=lambda d: d.rel_path) + if stats is not None: + stats["row_construction_ms"] = int((perf_counter() - started) * 1000) + stats["docs"] = len(out) + return out diff --git a/client-plugin/server/src/index_graph/knowledge/markdown.py b/client-plugin/server/src/index_graph/knowledge/markdown.py new file mode 100644 index 0000000..fbe9065 --- /dev/null +++ b/client-plugin/server/src/index_graph/knowledge/markdown.py @@ -0,0 +1,146 @@ +"""Zero-dependency GFM-lite markdown -> escaping-safe HTML for atlas docs.""" +from __future__ import annotations + +import re +from html import escape as _esc # &<>"' -> entities (quote=True by default) + +from .docs import _norm # shared normalizer: space/underscore -> dash, lower + +_CODE = re.compile(r"`([^`]+)`") +_IMAGE = re.compile(r"!\[([^\]]*)\]\([^)]*\)") +_WIKILINK = re.compile(r"\[\[\s*([^\]|]+?)\s*(?:\|\s*([^\]]*?)\s*)?\]\]") +_LINK = re.compile(r"\[([^\]]+)\]\(\s*([^()]*(?:\([^)]*\))*[^()]*?)\s*\)") +_BOLD = re.compile(r"\*\*([^*]+)\*\*") +_ITALIC = re.compile(r"(? str: + target, alias = m.group(1), m.group(2) + label = alias if alias else target # already inside escaped text + return ('%s' + % (_esc(_norm(target), quote=True), label)) + + +def _link_sub(m: "re.Match") -> str: + label, url = m.group(1), m.group(2) + if not _SAFE_URL.match(url): + return label # drop unsafe scheme, keep the text + return '%s' % (url, label) + + +def render_inline(text: str) -> str: + text = text.replace("\x00", "") # strip NUL so a doc body can't forge a code-span sentinel + codes: list[str] = [] + + def _stash(m: "re.Match") -> str: + codes.append("" + _esc(m.group(1)) + "") + return "\x00%d\x00" % (len(codes) - 1) # null-byte sentinel: absent from markdown, survives escaping + + text = _CODE.sub(_stash, text) + text = _esc(text) # escape all remaining literal text + text = _IMAGE.sub(lambda m: '' + m.group(1) + "", text) + text = _WIKILINK.sub(_wiki_sub, text) + text = _LINK.sub(_link_sub, text) + text = _BOLD.sub(r"\1", text) + text = _ITALIC.sub(r"\1", text) + text = re.sub(r"\x00(\d+)\x00", lambda m: codes[int(m.group(1))], text) + return text + + +_HEADING = re.compile(r"(#{1,6})\s+(.*)$") +_ULI = re.compile(r"\s*[-*+]\s+(.*)$") +_OLI = re.compile(r"\s*\d+[.)]\s+(.*)$") +_TASK = re.compile(r"\s*[-*+]\s+\[([ xX])\]\s+(.*)$") +_BQ = re.compile(r">\s?(.*)$") +_TABLE_SEP = re.compile(r"^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*$") + + +def _starts_block(line: str) -> bool: + return bool(_HEADING.match(line) or _ULI.match(line) or _OLI.match(line) + or line.startswith("```") or line.startswith(">") or "|" in line) + + +def _render_li(text: str) -> str: + task = _TASK.match(text) + if task: + checked = " checked" if task.group(1) in ("x", "X") else "" + return ('

    1. %s
    2. ' + % (checked, render_inline(task.group(2).strip()))) + body = (_ULI.match(text) or _OLI.match(text)).group(1) + return "
    3. " + render_inline(body.strip()) + "
    4. " + + +def _consume_list(lines: list[str], i: int) -> tuple[str, int]: + ordered = bool(_OLI.match(lines[i]) and not _ULI.match(lines[i])) + items: list[str] = [] + while i < len(lines) and (_ULI.match(lines[i]) or _OLI.match(lines[i])): + items.append(_render_li(lines[i])) + i += 1 + tag = "ol" if ordered else "ul" + return "<%s>\n%s\n" % (tag, "\n".join(items), tag), i + + +def _is_table(lines: list[str], i: int) -> bool: + return ("|" in lines[i] and i + 1 < len(lines) and bool(_TABLE_SEP.match(lines[i + 1]))) + + +def _row_cells(line: str) -> list[str]: + return [c.strip() for c in line.strip().strip("|").split("|")] + + +def _consume_table(lines: list[str], i: int) -> tuple[str, int]: + head = _row_cells(lines[i]); i += 2 # header row + separator row + body: list[str] = [] + while i < len(lines) and "|" in lines[i] and lines[i].strip(): + cells = _row_cells(lines[i]) + body.append("" + "".join("" + render_inline(c) + "" for c in cells) + "") + i += 1 + thead = "" + "".join("" + render_inline(c) + "" for c in head) + "" + return "\n%s\n%s\n
      " % (thead, "\n".join(body)), i + + +def render_markdown(text: str) -> str: + lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") + out: list[str] = [] + i, n = 0, len(lines) + while i < n: + line = lines[i] + if line.startswith("```"): + i += 1 + buf: list[str] = [] + while i < n and not lines[i].startswith("```"): + buf.append(lines[i]); i += 1 + i += 1 # skip the closing fence (or run off end) + out.append("
      " + _esc("\n".join(buf)) + "
      ") + continue + h = _HEADING.match(line) + if h: + lvl = len(h.group(1)) + out.append("%s" % (lvl, render_inline(h.group(2).strip()), lvl)) + i += 1; continue + if line.startswith(">"): + buf = [] + while i < n and lines[i].startswith(">"): + buf.append(_BQ.match(lines[i]).group(1)); i += 1 + out.append("
      " + render_inline(" ".join(b for b in buf if b)) + "
      ") + continue + if _is_table(lines, i): + block, i = _consume_table(lines, i) + out.append(block); continue + if _ULI.match(line) or _OLI.match(line): + block, i = _consume_list(lines, i) + out.append(block); continue + if line.strip() == "": + i += 1; continue + # Always consume the line that opened the paragraph. It may LOOK like a + # block starter (a prose line containing "|" that is not a table), but + # every real block was dispatched above; skipping it here would loop + # forever on the same line. + buf = [line] + i += 1 + while i < n and lines[i].strip() != "" and not _starts_block(lines[i]): + buf.append(lines[i]); i += 1 + out.append("

      " + render_inline(" ".join(buf)) + "

      ") + return "\n".join(out) diff --git a/client-plugin/server/src/index_graph/lsp/__init__.py b/client-plugin/server/src/index_graph/lsp/__init__.py new file mode 100644 index 0000000..ba0fa3b --- /dev/null +++ b/client-plugin/server/src/index_graph/lsp/__init__.py @@ -0,0 +1,15 @@ +"""LSP server: IDE-facing go-to-definition and find-references over the wave-1 +symbol graph, hand-rolled Content-Length framing, zero runtime dependencies. + +``index lsp --root ROOT`` starts a stdio JSON-RPC 2.0 language server that +answers ``textDocument/definition`` and ``textDocument/references`` from the +existing symbol-level call/reference graph. Every answer is evidence-backed +(a resolved file:line) or honestly empty; an unresolved reference is never +guessed into a jump, and a workspace that changed on disk is detected, not +silently answered from a stale graph. +""" +from __future__ import annotations + +from .server import LSPServer, serve + +__all__ = ["LSPServer", "serve"] diff --git a/client-plugin/server/src/index_graph/lsp/protocol.py b/client-plugin/server/src/index_graph/lsp/protocol.py new file mode 100644 index 0000000..c4e9c02 --- /dev/null +++ b/client-plugin/server/src/index_graph/lsp/protocol.py @@ -0,0 +1,93 @@ +"""LSP stdio framing and JSON-RPC 2.0 helpers, hand-rolled, zero dependencies. + +Unlike the newline-delimited MCP face, the Language Server Protocol frames each +message with a ``Content-Length`` header followed by a blank line and then the +UTF-8 JSON body (RFC 3156-style). These helpers read and write that framing over +binary streams and build the JSON-RPC response/error envelopes, nothing more: +dispatch and state live in ``server.py``. +""" +from __future__ import annotations + +import json +from typing import BinaryIO + +_HEADER_SEP = b"\r\n" +_HEADER_END = b"\r\n\r\n" + + +def read_message(stream: BinaryIO) -> dict | None: + """Read one Content-Length-framed JSON message, or None at end of stream. + + Reads header lines until the blank separator, honors the ``Content-Length`` + header (ignoring any others such as ``Content-Type``), then reads exactly + that many body bytes and parses them as JSON. A truncated frame or a body + that is not a JSON object reads as None (end of usable input). + """ + length: int | None = None + while True: + line = _read_header_line(stream) + if line is None: + return None # EOF before a complete header block + if line == b"": + break # blank line: header block ends + name, _, value = line.partition(b":") + if name.strip().lower() == b"content-length": + try: + length = int(value.strip()) + except ValueError: + return None + if length is None: + return None + body = _read_exact(stream, length) + if body is None: + return None + try: + msg = json.loads(body.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + return None + return msg if isinstance(msg, dict) else None + + +def _read_header_line(stream: BinaryIO) -> bytes | None: + """Read one CRLF-terminated header line (without the CRLF); None at EOF.""" + buf = bytearray() + while True: + ch = stream.read(1) + if not ch: + return None if not buf else bytes(buf) + buf += ch + if buf.endswith(_HEADER_SEP): + return bytes(buf[:-2]) + + +def _read_exact(stream: BinaryIO, n: int) -> bytes | None: + """Read exactly n bytes; None if the stream ends early.""" + chunks: list[bytes] = [] + remaining = n + while remaining > 0: + chunk = stream.read(remaining) + if not chunk: + return None + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def write_message(stream: BinaryIO, message: dict) -> None: + """Frame a JSON-RPC message with a Content-Length header and flush it.""" + body = json.dumps(message).encode("utf-8") + stream.write(b"Content-Length: " + str(len(body)).encode("ascii") + _HEADER_END) + stream.write(body) + flush = getattr(stream, "flush", None) + if callable(flush): + flush() + + +def make_response(rid, result) -> dict: + """A JSON-RPC 2.0 success response.""" + return {"jsonrpc": "2.0", "id": rid, "result": result} + + +def make_error(rid, code: int, message: str) -> dict: + """A JSON-RPC 2.0 error response.""" + return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}} diff --git a/client-plugin/server/src/index_graph/lsp/server.py b/client-plugin/server/src/index_graph/lsp/server.py new file mode 100644 index 0000000..1bcd85c --- /dev/null +++ b/client-plugin/server/src/index_graph/lsp/server.py @@ -0,0 +1,275 @@ +"""The LSP server: dispatch, workspace state, and the two providers. + +State is deliberately thin. On ``initialize``/``initialized`` the server builds +the wave-1 symbol graph for its single ``--root`` workspace and records a content +fingerprint plus a cheap metadata signature of the Python tree. Before answering +``textDocument/definition`` or ``textDocument/references`` it reuses metadata +only inside a short monotonic cache window, and only when file mtimes are outside +the recent-write uncertainty window. Otherwise it re-checks the content +fingerprint; if that moved, it returns a typed STALE error instead of an answer +derived from a graph that no longer describes the files. A deliberately restored +old mtime can still hide until the bounded content recheck. Every positive +answer is an evidence-backed Location from the graph; an unresolved name is null +(definition) or [] (references), never a guess, and never a symbol from outside +this root. +""" +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import sys +import time +from pathlib import Path + +from ..graph.walk import walk_files +from ..symbols import build_symbol_graph +from ..symbols.model import SymbolGraph +from . import protocol +from .protocol import make_error, make_response +from .symbols_lsp import (find_symbol_at_position, path_to_uri, to_lsp_location, + to_lsp_range) + +# JSON-RPC error codes. +METHOD_NOT_FOUND = -32601 +STALE_CODE = -32603 # Internal Error: the workspace changed since initialize + +_SERVER_NAME = "index-lsp" +_MTIME_UNCERTAINTY_NS = 1_000_000_000 +_CONTENT_RECHECK_NS = 2_000_000_000 + + +@dataclass(frozen=True) +class _StatSignature: + digest: str + newest_mtime_ns: int + + +def _wall_time_ns() -> int: + return time.time_ns() + + +def _monotonic_ns() -> int: + return time.monotonic_ns() + + +def _fingerprint(root: Path) -> str: + """A content fingerprint of the Python tree under root: SHA-256 over the + sorted (relative-path, file-sha) pairs. A changed, added, or removed .py + file moves the fingerprint; a stable tree keeps it byte-identical.""" + root = Path(root).resolve() + entries: list[str] = [] + for py in walk_files(root, suffixes=(".py",)): + try: + rel = py.relative_to(root).as_posix() + except ValueError: + rel = py.as_posix() + try: + digest = hashlib.sha256(py.read_bytes()).hexdigest() + except OSError: + digest = "unreadable" + entries.append(f"{rel}:{digest}") + entries.sort() + return hashlib.sha256("\n".join(entries).encode("utf-8")).hexdigest() + + +def _cheap_signature(root: Path) -> _StatSignature: + """A cheap staleness pre-check: SHA-256 over the sorted + (relative-path, mtime_ns, size) triples of the Python tree. This is + stat-only (no file reads), so it is O(files) rather than the + O(files * bytes) of [`_fingerprint`]. It is a bounded cache key, not an + authority: a match can skip the full read only outside the mtime uncertainty + window and only until the content-recheck interval expires.""" + root = Path(root).resolve() + entries: list[str] = [] + newest_mtime_ns = 0 + for py in walk_files(root, suffixes=(".py",)): + try: + rel = py.relative_to(root).as_posix() + except ValueError: + rel = py.as_posix() + try: + st = py.stat() + newest_mtime_ns = max(newest_mtime_ns, st.st_mtime_ns) + entries.append(f"{rel}:{st.st_mtime_ns}:{st.st_size}") + except OSError: + entries.append(f"{rel}:unstattable") + entries.sort() + digest = hashlib.sha256("\n".join(entries).encode("utf-8")).hexdigest() + return _StatSignature(digest, newest_mtime_ns) + + +class LSPServer: + """A single-workspace language server over the wave-1 symbol graph.""" + + def __init__(self, root: Path, trace: str = "off") -> None: + self.root = Path(root).resolve() + self.trace = trace + self.symbol_graph: SymbolGraph | None = None + self.fingerprint: str | None = None + self.cheap_sig: _StatSignature | None = None + self.last_content_check_mono_ns: int | None = None + self.should_exit = False + self._shutdown = False + + # --- lifecycle ----------------------------------------------------------- + + def _build(self) -> None: + """Build (or rebuild) the symbol graph and pin the current fingerprint.""" + self.symbol_graph = build_symbol_graph(self.root) + self.fingerprint = _fingerprint(self.root) + self.cheap_sig = _cheap_signature(self.root) + self.last_content_check_mono_ns = _monotonic_ns() + + def is_stale(self) -> bool: + """True when the Python tree changed on disk since the last build. + + Fail-closed around recent writes while staying fast in the common case: + a cheap stat-only signature is checked first, and a match skips the + full-content fingerprint only outside the recent-mtime uncertainty + window and only until the short monotonic content-recheck interval + expires. Metadata changes, recent mtimes, future mtimes, and expired + cache windows all fall back to the content fingerprint. + """ + if self.fingerprint is None: + return False + cheap_now = _cheap_signature(self.root) + wall_now = _wall_time_ns() + mono_now = _monotonic_ns() + if cheap_now == self.cheap_sig and self._metadata_cache_valid(cheap_now, wall_now, mono_now): + return False + # The cheap signature moved; the full-content fingerprint is the + # authority and decides staleness (fail-closed on a real change). + if _fingerprint(self.root) != self.fingerprint: + return True + # Either metadata moved while content stayed identical, or the bounded + # metadata cache expired. Refresh both checks from this content read. + self.cheap_sig = cheap_now + self.last_content_check_mono_ns = mono_now + return False + + def _metadata_cache_valid(self, stat_sig: _StatSignature, wall_now: int, mono_now: int) -> bool: + if self.last_content_check_mono_ns is None: + return False + mtime_age = wall_now - stat_sig.newest_mtime_ns + if stat_sig.newest_mtime_ns and (mtime_age < 0 or mtime_age <= _MTIME_UNCERTAINTY_NS): + return False + content_age = mono_now - self.last_content_check_mono_ns + return 0 <= content_age <= _CONTENT_RECHECK_NS + + # --- dispatch ------------------------------------------------------------ + + def handle_request(self, req: dict) -> dict | None: + method = req.get("method") + rid = req.get("id") + + if method == "initialize": + root = self._root_from_params(req.get("params") or {}) + if root is not None: + self.root = root + return make_response(rid, self._capabilities()) + if method == "initialized": + self._build() + return None + if method == "shutdown": + self._shutdown = True + return make_response(rid, None) + if method == "exit": + self.should_exit = True + return None + if method == "textDocument/didOpen": + return None # tracked implicitly; staleness is fingerprint-based + if method == "textDocument/definition": + return self._guarded(rid, self._definition, req.get("params") or {}) + if method == "textDocument/references": + return self._guarded(rid, self._references, req.get("params") or {}) + if rid is None: + return None # an unknown notification: nothing to answer + return make_error(rid, METHOD_NOT_FOUND, f"method not found: {method}") + + def _root_from_params(self, params: dict) -> Path | None: + """Honor an explicit rootUri/rootPath from the client, else keep --root.""" + uri = params.get("rootUri") + if isinstance(uri, str) and uri: + from .symbols_lsp import uri_to_path + return uri_to_path(uri) + path = params.get("rootPath") + if isinstance(path, str) and path: + return Path(path).resolve() + return None + + def _capabilities(self) -> dict: + from .. import __version__ + return {"capabilities": {"definitionProvider": True, + "referencesProvider": True, + "textDocumentSync": 1}, + "serverInfo": {"name": _SERVER_NAME, "version": __version__}} + + def _guarded(self, rid, handler, params: dict) -> dict: + """Run a provider, but first fail closed if the workspace is stale.""" + if self.symbol_graph is None: + self._build() + if self.is_stale(): + return make_error(rid, STALE_CODE, + "workspace changed on disk since initialize; " + "re-initialize the LSP server (stale graph blocked)") + return make_response(rid, handler(params)) + + # --- providers ----------------------------------------------------------- + + def _symbol_under_cursor(self, params: dict): + uri = (params.get("textDocument") or {}).get("uri") or "" + position = params.get("position") or {} + return find_symbol_at_position(uri, position, self.symbol_graph, self.root) + + def _definition(self, params: dict): + """Return an LSP Location for the symbol under the cursor, or null.""" + sym = self._symbol_under_cursor(params) + if sym is None: + return None # unresolved / outside root / not an identifier: never a guess + return to_lsp_location(sym, self.root) + + def _references(self, params: dict) -> list: + """Return every resolved caller of the symbol under the cursor. + + Unresolved references are excluded: they are not evidence-backed edges. + """ + sym = self._symbol_under_cursor(params) + if sym is None: + return [] + graph = self.symbol_graph + assert graph is not None + locations: list[dict] = [] + seen: set[tuple[str, int]] = set() + for call in graph.calls: + if call.to_symbol != sym.id: + continue + key = (call.evidence_file, call.evidence_line) + if key in seen: + continue + seen.add(key) + uri = path_to_uri(self.root / call.evidence_file) + locations.append({"uri": uri, "range": to_lsp_range(call.evidence_line)}) + include_decl = ((params.get("context") or {}).get("includeDeclaration")) + if include_decl: + locations.insert(0, to_lsp_location(sym, self.root)) + return locations + + # --- main loop ----------------------------------------------------------- + + def serve(self, stdin=None, stdout=None) -> int: + """Read Content-Length-framed JSON-RPC from stdin, answer on stdout.""" + stdin = stdin if stdin is not None else sys.stdin.buffer + stdout = stdout if stdout is not None else sys.stdout.buffer + while not self.should_exit: + msg = protocol.read_message(stdin) + if msg is None: + break + resp = self.handle_request(msg) + if resp is not None: + protocol.write_message(stdout, resp) + return 0 + + +def serve(root: Path, trace: str = "off", stdin=None, stdout=None) -> int: + """Start an LSPServer on root and run its stdio loop.""" + return LSPServer(root=root, trace=trace).serve(stdin, stdout) diff --git a/client-plugin/server/src/index_graph/lsp/symbols_lsp.py b/client-plugin/server/src/index_graph/lsp/symbols_lsp.py new file mode 100644 index 0000000..0af3f18 --- /dev/null +++ b/client-plugin/server/src/index_graph/lsp/symbols_lsp.py @@ -0,0 +1,123 @@ +"""Bridge the wave-1 symbol model to LSP positions and Locations. + +Two conversions and one lookup, all deterministic and evidence-backed: + - ``path_to_uri`` / ``uri_to_path``: file:// URIs the IDE speaks. + - ``to_lsp_location``: a SymbolDefinition -> an LSP Location (0-indexed line, + column 0; the client highlights the symbol name). + - ``find_symbol_at_position``: given a document and a cursor, name the symbol + the cursor sits on. A cursor on a definition line resolves to that symbol; + a cursor on a call resolves to the identifier written under it, matched + against real definitions (never guessed). A file outside the server root, + or a cursor on whitespace/comment, resolves to None. +""" +from __future__ import annotations + +from pathlib import Path +from urllib.parse import unquote, urlparse + +from ..symbols.model import SymbolDefinition, SymbolGraph + +# Identifier characters for the "word under the cursor" scan. +_IDENT = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_") + + +def path_to_uri(path: Path) -> str: + """A file:// URI for a filesystem path (resolved, percent-encoded). + + Uses ``Path.as_uri`` so the result is a well-formed ``file://`` URI on both + POSIX (``file:///tmp/x``) and Windows (``file:///C:/x``); hand-building it + from ``pathname2url`` yielded a single-slash ``file:/tmp/x`` on POSIX. + """ + return Path(path).resolve().as_uri() + + +def uri_to_path(uri: str) -> Path: + """The filesystem path a file:// URI names, resolved.""" + parsed = urlparse(uri) + raw = unquote(parsed.path) + # On Windows, urlparse leaves a leading slash before the drive letter. + if len(raw) >= 3 and raw[0] == "/" and raw[2] == ":": + raw = raw[1:] + return Path(raw).resolve() + + +def to_lsp_range(line_1indexed: int) -> dict: + """A zero-width LSP range at the start of a 1-indexed source line.""" + line0 = max(line_1indexed - 1, 0) + return {"start": {"line": line0, "character": 0}, + "end": {"line": line0, "character": 0}} + + +def to_lsp_location(sym: SymbolDefinition, root: Path) -> dict: + """An LSP Location for a SymbolDefinition, rooted at the workspace root.""" + uri = path_to_uri(Path(root) / sym.file) + return {"uri": uri, "range": to_lsp_range(sym.line)} + + +def _word_at(line: str, character: int) -> str | None: + """The identifier the cursor sits within, or None on non-identifier text.""" + if character < 0 or character > len(line): + return None + # A cursor exactly at end-of-word (character == len(word)) still counts. + if character == len(line) or line[character] not in _IDENT: + if character == 0 or line[character - 1] not in _IDENT: + return None + start = character + while start > 0 and line[start - 1] in _IDENT: + start -= 1 + end = character + while end < len(line) and line[end] in _IDENT: + end += 1 + word = line[start:end] + return word or None + + +def find_symbol_at_position( + uri: str, position: dict, graph: SymbolGraph, root: Path, +) -> SymbolDefinition | None: + """Resolve the symbol under an LSP cursor against the wave-1 graph. + + Only files inside ``root`` are considered; a file outside the server's + workspace resolves to None (never a cross-repo guess). Resolution defers + entirely to the wave-1 graph's own verdict, so nothing is guessed: + + - A cursor on a definition line resolves to that definition. + - A cursor on a call site resolves via the ``SymbolCall`` recorded at that + exact (file, line): a resolved call (``to_symbol`` set) jumps to its + target definition; an unresolved call (``to_symbol`` None, e.g. a + same-named symbol elsewhere with no import/call edge) resolves to None, + never to a bare-name match against an unrelated definition. + """ + root = Path(root).resolve() + path = uri_to_path(uri) + try: + rel = path.relative_to(root).as_posix() + except ValueError: + return None # document is outside this server's workspace root + try: + text = path.read_text(encoding="utf-8-sig") + except OSError: + return None + lines = text.splitlines() + line_no = position.get("line", 0) + if line_no < 0 or line_no >= len(lines): + return None + word = _word_at(lines[line_no], position.get("character", 0)) + if not word: + return None + line_1 = line_no + 1 + # Cursor on a definition line in this file: return that definition. + on_line = [s for s in graph.symbols + if s.file == rel and s.name == word and s.line == line_1] + if on_line: + return on_line[0] + # Cursor on a call site: defer to the graph's resolution for THIS exact call + # site. A resolved call names its target; an unresolved call names None. + by_id = {s.id: s for s in graph.symbols} + for call in graph.calls: + if (call.evidence_file == rel and call.evidence_line == line_1 + and call.to_name == word): + if call.to_symbol is not None: + return by_id.get(call.to_symbol) + return None # graph classified this call unresolved: never guess + return None diff --git a/client-plugin/server/src/index_graph/mcp.py b/client-plugin/server/src/index_graph/mcp.py new file mode 100644 index 0000000..5842b60 --- /dev/null +++ b/client-plugin/server/src/index_graph/mcp.py @@ -0,0 +1,730 @@ +"""A zero-dependency, MCP-shaped stdio protocol face for index. + +Newline-delimited JSON-RPC 2.0 over stdin/stdout, no SDK and no model. An agent host +connects and calls deterministic tools (graph, focus, verify, router, internals) to +consume index's verified map natively. This is the clean seam a router or orchestrator +composes through: the protocol pillar, not embeddings. Every tool reuses an existing +index function, so the protocol face adds a surface, never a second source of truth. +""" +from __future__ import annotations + +import hashlib +import json +import os +import sys +import traceback +from pathlib import Path +from time import time + +from . import __version__ +from .graph.progress import stderr_progress + +_PROTOCOL_VERSION = "2024-11-05" +_CACHE_SCHEMA = "index.mcp-cache-entry/v1" +_CACHE: dict[str, dict] = {} +_CACHEABLE_TOOLS = { + "index.context", + "index.context.envelope", + "index_graph", + "index_router", +} + + +def _configure_stdio() -> None: + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if callable(reconfigure): + try: + reconfigure(encoding="utf-8", errors="replace") + except (OSError, ValueError): + pass + + +def _tool_error_payload(name: str, args: dict, exc: BaseException) -> str: + root = args.get("root", "") if isinstance(args, dict) else "" + payload = { + "schema": "index.mcp-tool-error/v1", + "tool": name, + "status": "UNVERIFIABLE", + "error_type": type(exc).__name__, + "message": str(exc), + "root": str(root), + "recoverable": not isinstance(exc, (KeyboardInterrupt,)), + "next_actions": [ + "Inspect the root configuration and filesystem permissions.", + "Run the matching index CLI command with --json to reproduce outside the MCP host.", + "If this came from a large workspace scan, reduce focus or add [scan].prune entries.", + ], + } + if os.environ.get("INDEX_MCP_DEBUG_ERRORS") == "1": + payload["traceback"] = traceback.format_exception(type(exc), exc, exc.__traceback__) + return json.dumps(payload, indent=2, sort_keys=True) + + +def _cache_ttl_seconds() -> float: + raw = os.environ.get("INDEX_MCP_CACHE_TTL_SECONDS", "900") + try: + return max(0.0, float(raw)) + except ValueError: + return 900.0 + + +def _cache_dir() -> Path: + raw = os.environ.get("INDEX_MCP_CACHE_DIR") + if raw: + return Path(raw) + base = os.environ.get("LOCALAPPDATA") + if base: + return Path(base) / "index_graph" / "mcp-cache" + return Path.home() / ".cache" / "index_graph" / "mcp-cache" + + +def _sha256_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _workspace_signature(root: Path) -> str: + """Cheap MCP cache identity. + + Freshness is handled by typed fingerprints on the verification/envelope + surfaces. The MCP cache key must not recursively walk a large workspace before + it can discover whether a warm entry exists. + """ + from .cache import workspace_signature + + return workspace_signature(root) + + +def _interactive_budget_ms(args: dict) -> int: + raw = args.get("budget_ms") + if raw is None or raw == "": + from .scan import default_interactive_budget_ms + return default_interactive_budget_ms() + try: + value = int(raw) + except (TypeError, ValueError) as exc: + raise ValueError("budget_ms must be an integer millisecond budget") from exc + if value < 0: + raise ValueError("budget_ms must be non-negative") + return value + + +def _cache_args(args: dict, *, budget_ms: int | None = None) -> dict: + stable = dict(args) + if budget_ms is not None: + stable["budget_ms"] = budget_ms + return stable + + +def _cache_key(name: str, root: Path, args: dict) -> str: + stable_args = { + key: value + for key, value in args.items() + if key not in {"root"} and isinstance(value, (str, int, float, bool, type(None), list, dict)) + } + payload = { + "tool": name, + "root": str(root), + "args": stable_args, + "workspace_signature": _workspace_signature(root), + "tool_version": __version__, + } + return _sha256_text(json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)) + + +def _cache_path(key: str) -> Path: + return _cache_dir() / f"{key}.json" + + +def _cache_read(key: str) -> str | None: + ttl = _cache_ttl_seconds() + if ttl <= 0: + return None + now = time() + entry = _CACHE.get(key) + if entry and now - float(entry.get("created_at", 0.0)) <= ttl: + return str(entry.get("text", "")) + path = _cache_path(key) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, json.JSONDecodeError): + return None + if data.get("schema") != _CACHE_SCHEMA: + return None + created_at = float(data.get("created_at", 0.0)) + if now - created_at > ttl: + return None + text = str(data.get("text", "")) + _CACHE[key] = {"created_at": created_at, "text": text} + return text + + +def _cache_write(key: str, text: str) -> str: + ttl = _cache_ttl_seconds() + if ttl <= 0: + return text + entry = {"schema": _CACHE_SCHEMA, "created_at": time(), "text": text} + _CACHE[key] = {"created_at": entry["created_at"], "text": text} + try: + path = _cache_path(key) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(entry, separators=(",", ":")), encoding="utf-8") + except OSError: + pass + return text + + +def _with_cache(name: str, root: Path, args: dict, build): + if "no_cache" in args and not isinstance(args["no_cache"], bool): + raise ValueError("no_cache must be a boolean") + if args.get("no_cache"): + return build() + if name not in _CACHEABLE_TOOLS: + return build() + key = _cache_key(name, root, args) + cached = _cache_read(key) + if cached is not None: + return cached + return _cache_write(key, build()) + + + + +def _bool_arg(args: dict, key: str, default: bool = False) -> bool: + value = args.get(key, default) + if isinstance(value, bool): + return value + raise ValueError(f"{key} must be a boolean") + +def _root_schema(extra: dict | None = None, required: list | None = None) -> dict: + props = {"root": {"type": "string", "description": "workspace root path"}} + if extra: + props.update(extra) + return {"type": "object", "properties": props, "required": required or ["root"]} + + +def _workspace_schema(extra: dict | None = None, required: list | None = None) -> dict: + props = { + "budget_ms": { + "type": "integer", + "description": "optional repository-discovery time budget in milliseconds; 0 means unbounded", + } + } + if extra: + props.update(extra) + return _root_schema(props, required=required) + + +def _hints(title: str, *, read_only: bool = True, idempotent: bool = True) -> dict: + return {"title": title, "readOnlyHint": read_only, "destructiveHint": False, + "idempotentHint": idempotent, "openWorldHint": False} + + +# MCP tool annotations. A hint describes the tool to the client and grants +# nothing. index.map writes only when given resume_state; router jobs keep +# private state files. +TOOL_ANNOTATIONS = { + "index.map": _hints("Map a repository", read_only=False), + "index.context": _hints("Repository dependency context"), + "index.context.envelope": _hints("Budgeted context envelope"), + "index.select": _hints("Select paths with receipts"), + "index.invalidate": _hints("Name what changes invalidate"), + "index.wiki": _hints("Build or verify a repository wiki"), + "index.symbol-graph": _hints("Symbol call graph"), + "index.symbol-definition": _hints("Go to a symbol definition"), + "index.symbol-references": _hints("Find symbol references"), + "index.symbol-implementations": _hints("Find symbol implementations"), + "index.status": _hints("Index status"), + "index.doctor": _hints("Index readiness check"), + "index_graph": _hints("Workspace dependency graph"), + "index_focus": _hints("Repository neighborhood"), + "index_verify": _hints("Check a structural claim"), + "index_router": _hints("Build a workspace map"), + "index.route": _hints("Context envelope for named paths"), + "index_internals": _hints("Module dependency graph"), + "index.router.job.start": _hints("Start a workspace map job", read_only=False, + idempotent=False), + "index.router.job.status": _hints("Workspace map job status"), + "index.router.job.result": _hints("Workspace map job result"), + "index.router.job.cancel": _hints("Cancel a workspace map job", read_only=False), + "index.router.job.resume": _hints("Resume a workspace map job", read_only=False, + idempotent=False), +} + + +def annotate(tool: dict) -> dict: + notes = dict(TOOL_ANNOTATIONS[tool["name"]]) + return {**tool, "title": notes["title"], "annotations": notes} + + +def _tool_defs() -> list[dict]: + return [annotate(tool) for tool in _raw_tool_defs()] + + +def _raw_tool_defs() -> list[dict]: + from .route import tool_definition as route_tool_definition + from .router_job_surface import tool_definitions + return [ + {"name": "index.map", + "description": "Repository inventory map as JSON, matching the `index map --json` CLI surface.", + "inputSchema": _root_schema({"resume_state": {"type": "string", "description": "optional JSONL state file for resumable complete map builds"}})}, + {"name": "index.context", + "description": "Repo-level dependency context pack as JSON, matching the `index context --json` CLI surface.", + "inputSchema": _workspace_schema()}, + {"name": "index.context.envelope", + "description": "Budgeted, receipt-backed context envelope for large-codebase agent workflows.", + "inputSchema": _workspace_schema({ + "budget": {"type": "integer"}, + "focus": {"type": "string"}, + "hops": {"type": "integer"}, + "bounded_output": {"type": "boolean"}, + })}, + {"name": "index.select", + "description": "Path selection with typed rejection receipts; candidates reconcile to selected + rejected, matching the `index select --json` CLI surface.", + "inputSchema": _root_schema({ + "suffixes": {"type": "array", "items": {"type": "string"}}, + "max_files": {"type": "integer"}, + })}, + {"name": "index.invalidate", + "description": "Diff the tree against a pinned fingerprint and name exactly what the changes invalidate (index.invalidation/1). Without 'pin', mints and returns a pin of the current tree, matching the `index invalidate` CLI surface.", + "inputSchema": _root_schema({ + "pin": {"type": "string", "description": "path to a pin JSON minted earlier"}, + })}, + {"name": "index.wiki", + "description": "Single-repo verified wiki pack (pages derived from the module graph, sealed manifest, commit-pinned), matching the `index wiki --format json` CLI surface; pass verify=PATH to re-check a sealed artifact (MATCH/DRIFT/UNVERIFIABLE).", + "inputSchema": _root_schema({ + "verify": {"type": "string", + "description": "path to a sealed wiki artifact to verify"}, + })}, + {"name": "index.symbol-graph", + "description": "Symbol-level call/reference graph for one repo (root IS the repo): function/class/method definitions plus resolved (exact within-module, best-effort cross-module) and honestly-unresolved calls, matching the `index internals-symbols --json` CLI surface.", + "inputSchema": _root_schema()}, + {"name": "index.symbol-definition", + "description": "GO-TO-DEFINITION: the file:line of every symbol whose id or bare name matches, derived (never guessed) from the AST.", + "inputSchema": _root_schema({"symbol": {"type": "string", + "description": "symbol id (module::name) or bare name"}}, + required=["root", "symbol"])}, + {"name": "index.symbol-references", + "description": "FIND-REFERENCES: every resolved caller of a symbol, each with file:line evidence; unresolved references are reported separately, never as a caller.", + "inputSchema": _root_schema({"symbol": {"type": "string", + "description": "symbol id (module::name) or bare name"}}, + required=["root", "symbol"])}, + {"name": "index.symbol-implementations", + "description": "FIND-IMPLEMENTATIONS: in-repo subclasses of a class or overrides of a method, each with file:line evidence and an exact/cross_module resolution label; an external or unbindable base yields no edge (never guessed).", + "inputSchema": _root_schema({"symbol": {"type": "string", + "description": "class or method symbol id (module::name / Class::method) or bare name"}}, + required=["root", "symbol"])}, + {"name": "index.status", + "description": "Project Telos operator-spine status action envelope, matching the `index status --json` CLI surface.", + "inputSchema": {"type": "object", "properties": {}}}, + {"name": "index.doctor", + "description": "Project Telos operator-spine readiness checks action envelope, matching the `index doctor --json` CLI surface.", + "inputSchema": {"type": "object", "properties": {}}}, + {"name": "index_graph", + "description": "Repo-level dependency graph (relations, roles, cycles) as JSON.", + "inputSchema": _workspace_schema({"no_cache": {"type": "boolean", + "description": "Bypass result and per-repository graph caches."}})}, + {"name": "index_focus", + "description": "A repo's dependency neighborhood plus a preservation manifest of what was dropped at the boundary.", + "inputSchema": _workspace_schema({"repo": {"type": "string"}, "hops": {"type": "integer"}}, + required=["root", "repo"])}, + {"name": "index_verify", + "description": "Ground a structural claim. Pass depends 'A -> B' or exists 'NAME'. Returns MATCH/REFUTED/UNVERIFIABLE with file:line evidence.", + "inputSchema": _workspace_schema({"depends": {"type": "string"}, "exists": {"type": "string"}})}, + {"name": "index_router", + "description": "Build a deterministic CLAUDE.md/AGENTS.md workspace map synchronously. For large workspaces, use index.router.job.start, then index.router.job.status and index.router.job.result to avoid an interactive request timeout.", + "inputSchema": _workspace_schema({"max_docs": {"type": "integer"}, + "no_cache": {"type": "boolean", + "description": "Bypass result and per-repository graph caches."}})}, + route_tool_definition(), + {"name": "index_internals", + "description": "Intra-repo module dependency graph for one repo, with cycles and coverage.", + "inputSchema": _workspace_schema({"repo": {"type": "string"}}, required=["root", "repo"])}, + *tool_definitions(), + ] + + +def _repo_paths(root: Path, *, budget_ms: int | None = None) -> dict: + from .config import load_config + from .scan import ( + ScanBudget, + ScanBudgetExceeded, + discover_repos, + enforce_interactive_repo_limit, + repo_key_map, + ) + + config = load_config(None, root) + budget = ScanBudget(budget_ms) + skipped: list[str] = [] + repos = discover_repos( + root, + config, + skipped=skipped, + checkpoint=budget.checkpoint if budget.budget_ms > 0 else None, + ) + if budget.exhausted: + raise ScanBudgetExceeded(root=root, budget=budget, repo_count=len(repos), skipped=skipped) + keyed = repo_key_map( + root, + repos, + include_root_repo=config.include_root_repo, + ) + enforce_interactive_repo_limit(len(keyed), budget_ms=budget.budget_ms) + return keyed + + +def _symbol_matches(sym, query: str) -> bool: + return sym.id == query or sym.name == query + + +def _symbol_tool(name: str, root: Path, args: dict) -> str: + from .symbols import (build_symbol_navigator, find_implementations, + symbol_graph_to_payload) + g, edges = build_symbol_navigator(root) + if name == "index.symbol-graph": + return json.dumps(symbol_graph_to_payload(g), indent=2, sort_keys=True) + query = (args.get("symbol") or "").strip() + if not query: + raise ValueError("missing required argument: symbol") + if name == "index.symbol-implementations": + impls = find_implementations(g, edges, query) + return json.dumps({"symbol": query, **impls}, indent=2, sort_keys=True) + if name == "index.symbol-definition": + defs = [{"id": s.id, "name": s.name, "kind": s.kind, "file": s.file, + "line": s.line, "parent": s.parent} + for s in g.symbols if _symbol_matches(s, query)] + return json.dumps({"symbol": query, "definitions": defs}, + indent=2, sort_keys=True) + # index.symbol-references: resolved callers + separately, unresolved refs + targets = {s.id for s in g.symbols if _symbol_matches(s, query)} + refs = [{"from_symbol": c.from_symbol, "file": c.evidence_file, + "line": c.evidence_line, "raw": c.raw, "confidence": c.confidence} + for c in g.calls if c.to_symbol in targets] + unresolved = [{"from_symbol": c.from_symbol, "to_name": c.to_name, + "file": c.evidence_file, "line": c.evidence_line} + for c in g.calls if c.to_symbol is None and c.to_name == query] + return json.dumps({"symbol": query, "references": refs, + "unresolved_references": unresolved}, indent=2, sort_keys=True) + + +def call_tool(name: str, args: dict, response_id=None) -> str: + if name.startswith("index.router.job."): + from .router_job_surface import call_router_job + return json.dumps(call_router_job(name.removeprefix("index.router.job."), args), + indent=2, sort_keys=True) + + if name == "index.status": + from .flagship import status_payload + return json.dumps(status_payload(), indent=2, sort_keys=True) + + if name == "index.doctor": + from .flagship import doctor_payload + return json.dumps(doctor_payload(), indent=2, sort_keys=True) + + if name == "index.select": + # a missing root yields a not-found receipt (CLI parity), not an error + from .context.select import run_select + if "root" not in args: + raise ValueError("missing required argument: root") + suffixes = tuple(args["suffixes"]) if args.get("suffixes") else None + payload = run_select(Path(args["root"]), suffixes, args.get("max_files")) + return json.dumps(payload, indent=2, sort_keys=True) + + if name == "index.route": + from .route import call_route + return json.dumps(call_route(args, response_id=response_id), indent=2, sort_keys=True) + + from .graph.build import build_graph + from .context.focus import FocusRejection, focus_rejection + from .context.pack import to_json, closure, preservation, focus_subgraph + + if "root" not in args: + raise ValueError("missing required argument: root") + root = Path(args["root"]).resolve() + if not root.is_dir(): + raise ValueError(f"root not found: {root}") + + if name == "index.wiki": + # single-repo altitude: the root IS the repo, no workspace scan + if args.get("verify"): + from .wiki import run_verify + return json.dumps(run_verify(Path(args["verify"]), root), + indent=2, sort_keys=True) + from .wiki import build_wiki_pack + return json.dumps(build_wiki_pack(root), indent=2, sort_keys=True) + + if name in ("index.symbol-graph", "index.symbol-definition", + "index.symbol-references", "index.symbol-implementations"): + return _symbol_tool(name, root, args) + + if name == "index.invalidate": + # without 'pin' this mints one; with 'pin' it emits the typed report. + # both are payloads, matching the CLI's --out / --pin modes. + from .freshness.invalidate import mint_pin + from .freshness.invalidate_cli import run_invalidate + if not args.get("pin"): + return json.dumps(mint_pin(root), indent=2, sort_keys=True) + pin_path = Path(args["pin"]) + try: + pin = json.loads(pin_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"cannot read pin {pin_path}: {exc}") + return json.dumps(run_invalidate(root, pin), indent=2, sort_keys=True) + + if name == "index.map": + from .config import load_config + from .scan import build_map + resume_state = Path(args["resume_state"]) if args.get("resume_state") else None + return json.dumps( + build_map(root, load_config(None, root), __version__, resume_state=resume_state).to_json(), + indent=2, + sort_keys=True, + ) + + if name in ("index.context", "index_graph"): + budget_ms = _interactive_budget_ms(args) + + def _build_context() -> str: + paths = _repo_paths(root, budget_ms=budget_ms) + return json.dumps(to_json(build_graph(paths, executor="process", + use_cache=not args.get("no_cache", False), + on_progress=stderr_progress())), + indent=2, sort_keys=True) + + return _with_cache( + name, + root, + _cache_args(args, budget_ms=budget_ms), + _build_context, + ) + + if name == "index.context.envelope": + from .context.envelope import build_context_envelope + budget_ms = _interactive_budget_ms(args) + bounded_output = _bool_arg(args, "bounded_output", False) + + def _build_envelope(): + paths = _repo_paths(root, budget_ms=budget_ms) + try: + env = build_context_envelope( + build_graph(paths), + root=root, + token_budget=int(args.get("budget", 1200)), + focus=args.get("focus"), + hops=args.get("hops"), + bounded_output=bounded_output, + bounded_output_transport=( + "mcp_jsonrpc_tool_response" if bounded_output else "canonical_json" + ), + mcp_response_id=response_id, + ) + except FocusRejection as exc: + # an unresolvable focus is a typed receipt, not a protocol error + # (the index.select not-found precedent) + return json.dumps(exc.receipt, indent=2, sort_keys=True) + return json.dumps(env, indent=2, sort_keys=True) + + if bounded_output: + # The measurement includes the JSON-RPC response id, so cached text + # from a different id could understate or overstate the wrapper. + return _build_envelope() + return _with_cache( + name, + root, + _cache_args(args, budget_ms=budget_ms), + _build_envelope, + ) + + if name == "index_focus": + budget_ms = _interactive_budget_ms(args) + repo_paths = _repo_paths(root, budget_ms=budget_ms) + graph = build_graph(repo_paths) + repo = args.get("repo") or "" + names = {n.name for n in graph.repos} + if repo not in names: + return json.dumps(focus_rejection(repo, names), indent=2, sort_keys=True) + hops = args.get("hops") + keep = closure(list(graph.edges), repo, hops=hops) + pack = to_json(focus_subgraph(graph, keep)) + pack["preserved"] = preservation(list(graph.edges), keep, repo, hops) + return json.dumps(pack, indent=2, sort_keys=True) + + if name == "index_verify": + from .verify import build_verification + budget_ms = _interactive_budget_ms(args) + repo_paths = _repo_paths(root, budget_ms=budget_ms) + pack = to_json(build_graph(repo_paths)) + if args.get("depends"): + if "->" not in args["depends"]: + raise ValueError("depends must be 'A -> B'") + frm, _, to = args["depends"].partition("->") + claim = {"kind": "depends", "from": frm.strip(), "to": to.strip()} + elif args.get("exists"): + claim = {"kind": "exists", "name": args["exists"].strip()} + else: + raise ValueError("index_verify needs 'depends' or 'exists'") + rec = build_verification(pack, claim, tool_version=__version__, + recheck="index verify (via mcp)") + return json.dumps(rec, indent=2, sort_keys=True) + + if name == "index_router": + from .knowledge.atlas import build_router_pack + from .knowledge.docs import discover_router_docs + from .router import render_router + from .router_inventory import build_router_inventory + + budget_ms = _interactive_budget_ms(args) + max_docs = max(0, int(args.get("max_docs", 500))) + + def _build_router() -> str: + inventory = build_router_inventory(root, budget_ms=budget_ms) + return render_router(build_router_pack( + build_graph(inventory.repo_paths, executor="process", + use_cache=not args.get("no_cache", False), + on_progress=stderr_progress(), + file_lists=inventory.repo_file_lists), + discover_router_docs(root, paths=inventory.router_doc_paths), + inventory.repo_dirs, + ), max_docs=max_docs) + + return _with_cache( + name, + root, + _cache_args(args, budget_ms=budget_ms), + _build_router, + ) + + if name == "index_internals": + from .internals import build_internals + budget_ms = _interactive_budget_ms(args) + repo_paths = _repo_paths(root, budget_ms=budget_ms) + repo = args.get("repo") + if repo not in repo_paths: + raise ValueError(f"unknown repo: {repo}") + g = build_internals(repo_paths[repo], repo) + payload = { + "repo": g.repo, + "modules": [{"id": m.id, "path": m.path, "language": m.language} for m in g.modules], + "edges": [{"from": e.from_id, "to": e.to_id, "file": e.evidence_file, + "line": e.evidence_line, "raw": e.raw} for e in g.edges], + "cycles": [list(c) for c in g.cycles], + "fan_in": g.fan_in, "fan_out": g.fan_out, + "coverage": {"complete": g.coverage.complete, + "modules": g.coverage.modules, + "internal_edges": g.coverage.internal_edges, + "parse_errors": list(g.coverage.parse_errors), + "dynamic_imports": [{"file": f, "line": ln} + for f, ln in g.coverage.dynamic_imports]}, + } + return json.dumps(payload, indent=2, sort_keys=True) + + raise ValueError(f"unknown tool: {name}") + + +def handle_request(req: dict) -> dict | None: + """Handle one JSON-RPC request; return the response dict, or None for a notification.""" + method = req.get("method") + rid = req.get("id") + + if method == "initialize": + return {"jsonrpc": "2.0", "id": rid, "result": { + "protocolVersion": _PROTOCOL_VERSION, + "capabilities": {"tools": {}}, + "serverInfo": {"name": "index-graph", "version": __version__}}} + if rid is None: + return None # a notification (e.g. notifications/initialized): no response + if method == "ping": + return {"jsonrpc": "2.0", "id": rid, "result": {}} + if method == "tools/list": + return {"jsonrpc": "2.0", "id": rid, "result": {"tools": _tool_defs()}} + if method == "tools/call": + params = req.get("params") or {} + name = params.get("name") + args = params.get("arguments") or {} + if name not in {t["name"] for t in _tool_defs()}: + return {"jsonrpc": "2.0", "id": rid, + "error": {"code": -32602, "message": f"unknown tool: {name!r}"}} + try: + text = call_tool(name, args, response_id=rid) + return {"jsonrpc": "2.0", "id": rid, + "result": {"content": [{"type": "text", "text": text}], "isError": False}} + except BaseException as exc: + return {"jsonrpc": "2.0", "id": rid, + "result": {"content": [{"type": "text", "text": _tool_error_payload(name, args, exc)}], + "isError": True}} + return {"jsonrpc": "2.0", "id": rid, + "error": {"code": -32601, "message": f"method not found: {method}"}} + + +def _decode_line(line) -> str: + if isinstance(line, bytes): + return line.decode("utf-8", "replace") + return str(line) + + +def _read_framed_body(first_line, stdin) -> str | None: + header = _decode_line(first_line).strip() + try: + length = int(header.split(":", 1)[1].strip()) + except (IndexError, ValueError): + return None + while True: + line = stdin.readline() + if line in ("", b""): + return None + if _decode_line(line).strip() == "": + break + body = stdin.read(length) + if body in ("", b""): + return None + if isinstance(body, bytes): + return body.decode("utf-8", "replace") + return str(body) + + +def _write_response(stdout, resp: dict, framed: bool) -> None: + body = json.dumps(resp) + if not framed: + stdout.write(body + "\n") + stdout.flush() + return + payload = body.encode("utf-8") + frame = f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii") + payload + buffer = getattr(stdout, "buffer", None) + if buffer is not None: + buffer.write(frame) + buffer.flush() + return + stdout.write(frame.decode("utf-8")) + stdout.flush() + + +def serve(stdin=None, stdout=None) -> int: + """Read MCP stdio frames or newline-delimited JSON-RPC from stdin.""" + _configure_stdio() + stdin = stdin if stdin is not None else sys.stdin + stdout = stdout if stdout is not None else sys.stdout + reader = getattr(stdin, "buffer", stdin) + while True: + line = reader.readline() + if line in ("", b""): + break + text = _decode_line(line).strip() + framed = text.lower().startswith("content-length:") + if framed: + text = _read_framed_body(line, reader) + if text is None: + continue + else: + text = text.strip() + if not line: + continue + try: + req = json.loads(text) + except json.JSONDecodeError: + continue # no id to address a parse error to; conformant hosts send valid frames + resp = handle_request(req) + if resp is not None: + _write_response(stdout, resp, framed) + return 0 diff --git a/client-plugin/server/src/index_graph/model.py b/client-plugin/server/src/index_graph/model.py new file mode 100644 index 0000000..6b591f9 --- /dev/null +++ b/client-plugin/server/src/index_graph/model.py @@ -0,0 +1,93 @@ +"""Pure data model for a workspace repository map. No I/O, git, or config.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class RepoRow: + path: str + class_: str + branch: str + head: str + origin: str + dirty_count: int + untracked_count: int + markers: tuple[str, ...] + metadata_status: str = "ok" + metadata_error: str | None = None + + def to_json(self) -> dict[str, Any]: + data = { + "path": self.path, + "class": self.class_, + "branch": self.branch, + "head": self.head, + "origin": self.origin, + "dirty_count": self.dirty_count, + "untracked_count": self.untracked_count, + "markers": list(self.markers), + "metadata_status": self.metadata_status, + } + if self.metadata_error: + data["metadata_error"] = self.metadata_error + return data + + +@dataclass(frozen=True) +class Map: + schema_version: int + tool_version: str + generated_at: str + root_sha256_prefix: str + root: str | None + absolute_paths_included: bool + repo_count: int + dirty_count: int + class_counts: dict[str, int] + top_level: tuple[dict[str, Any], ...] + repositories: tuple[RepoRow, ...] + annotations: dict[str, Any] = field(default_factory=dict) + + @property + def metadata_ok_count(self) -> int: + return sum(1 for row in self.repositories if row.metadata_status == "ok") + + @property + def metadata_unknown_count(self) -> int: + return len(self.repositories) - self.metadata_ok_count + + @property + def metadata_status(self) -> str: + return "ok" if self.metadata_unknown_count == 0 else "partial" + + @property + def dirty_count_status(self) -> str: + return "complete" if self.metadata_unknown_count == 0 else "known_only" + + def to_json(self) -> dict[str, Any]: + data: dict[str, Any] = { + "schema_version": self.schema_version, + "tool_version": self.tool_version, + "generated_at": self.generated_at, + "root_sha256_prefix": self.root_sha256_prefix, + "absolute_paths_included": self.absolute_paths_included, + "repo_count": self.repo_count, + "dirty_count": self.dirty_count, + "dirty_count_status": self.dirty_count_status, + "metadata_status": self.metadata_status, + "metadata_ok_count": self.metadata_ok_count, + "metadata_unknown_count": self.metadata_unknown_count, + "class_counts": self.class_counts, + "top_level": list(self.top_level), + "repositories": [row.to_json() for row in self.repositories], + } + if self.root is not None: + data["root"] = self.root + if self.annotations: + data["annotations"] = self.annotations + return data diff --git a/client-plugin/server/src/index_graph/route.py b/client-plugin/server/src/index_graph/route.py new file mode 100644 index 0000000..e02201a --- /dev/null +++ b/client-plugin/server/src/index_graph/route.py @@ -0,0 +1,250 @@ +"""Explicit-path task routing backed by the context-envelope contract.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Sequence + +from .context.envelope import build_context_envelope +from .graph.build import build_graph +from .scan import repo_key_map + +SCHEMA = "index.route/v1" + + +def _root_hash(root: Path) -> str: + return hashlib.sha256(str(root.resolve()).encode("utf-8")).hexdigest()[:16] + + +def _rel(path: Path, root: Path) -> str: + try: + return path.relative_to(root).as_posix() or "." + except ValueError: + return path.name + + +def _path_hash(path: Path) -> str: + return hashlib.sha256(str(path).encode("utf-8", errors="surrogatepass")).hexdigest() + + +def _reject(path: str, reason_code: str, rule_ref: str, **extra: object) -> dict: + return {"path": path, "reason_code": reason_code, "rule_ref": rule_ref, **extra} + + +def _outside_root_rejection(raw: Path, candidate: Path) -> dict: + digest = _path_hash(candidate) + return _reject( + f"outside-root:{digest[:16]}", + "outside-root", + "route.explicit_path.contained", + path_kind="outside-root", + path_sha256=digest, + was_absolute=raw.is_absolute(), + ) + + +def _selected(path: Path, root: Path, key: str) -> dict: + return {"key": key, "path": _rel(path, root)} + + +def _normalize_candidate(root: Path, raw_path: str) -> tuple[Path | None, dict | None]: + if not isinstance(raw_path, str) or not raw_path.strip(): + return None, _reject(str(raw_path), "empty-path", "route.explicit_path.nonempty") + raw = Path(raw_path) + candidate = raw.resolve() if raw.is_absolute() else (root / raw).resolve() + try: + candidate.relative_to(root) + except ValueError: + return None, _outside_root_rejection(raw, candidate) + portable_path = _rel(candidate, root) + if not candidate.exists(): + return None, _reject(portable_path, "not-found", "route.explicit_path.exists") + if not candidate.is_dir(): + return None, _reject(portable_path, "not-directory", "route.explicit_path.directory") + if not (candidate / ".git").exists(): + return None, _reject(portable_path, "not-repository", "route.explicit_path.repository") + return candidate, None + + +def _portable_envelope(envelope: dict) -> dict: + data = json.loads(json.dumps(envelope, sort_keys=True)) + data["root"] = "." + data["recheck"] = { + **data.get("recheck", {}), + "command": "index route --root ROOT --path PATH --json", + } + return data + + +def _empty_receipt(root: Path, *, paths: Sequence[str], rejected: list[dict]) -> dict: + return { + "schema": SCHEMA, + "status": "UNVERIFIABLE", + "failure_codes": ["no_selected_repositories"], + "root": {"sha256_prefix": _root_hash(root)}, + "mode": "explicit-path", + "selection": {"selected": [], "rejected": rejected}, + "reconciliation": { + "candidate_count": len(paths), + "selected_count": 0, + "rejected_count": len(rejected), + "omitted_count": 0, + }, + "envelope": None, + "privacy": {"absolute_paths_included": False}, + "recheck": {"command": "index route --root ROOT --path PATH --json"}, + } + + +def build_route( + root: Path | str, + *, + paths: Sequence[str], + token_budget: int = 1200, + hops: int | None = None, +) -> dict: + """Build a route envelope for explicit repository paths without workspace discovery.""" + root_path = Path(root).resolve() + raw_paths = [str(item) for item in paths] + if token_budget < 1: + raise ValueError("budget must be a positive integer") + if hops is not None and hops < 0: + raise ValueError("hops must be non-negative") + rejected: list[dict] = [] + selected_paths: list[Path] = [] + seen: set[Path] = set() + for raw_path in raw_paths: + candidate, rejection = _normalize_candidate(root_path, raw_path) + if rejection is not None: + rejected.append(rejection) + continue + assert candidate is not None + if candidate in seen: + rejected.append(_reject(_rel(candidate, root_path), "duplicate-path", "route.explicit_path.unique")) + continue + seen.add(candidate) + selected_paths.append(candidate) + if not selected_paths: + return _empty_receipt(root_path, paths=raw_paths, rejected=rejected) + + keyed = repo_key_map(root_path, sorted(selected_paths), include_root_repo=True) + graph = build_graph(keyed, executor="thread") + envelope = _portable_envelope(build_context_envelope( + graph, + root=root_path, + token_budget=token_budget, + hops=hops, + )) + envelope_verified = envelope.get("verification_verdict") == "MATCH" + if not envelope_verified: + status = "UNVERIFIABLE" + failure_codes = ["envelope_unverifiable"] + if rejected: + failure_codes.append("candidate_rejected") + elif rejected: + status = "PARTIAL" + failure_codes = ["candidate_rejected"] + else: + status = "MATCH" + failure_codes = [] + return { + "schema": SCHEMA, + "status": status, + "failure_codes": failure_codes, + "root": {"sha256_prefix": _root_hash(root_path)}, + "mode": "explicit-path", + "selection": { + "selected": [ + _selected(path, root_path, key) + for key, path in sorted(keyed.items(), key=lambda item: item[0]) + ], + "rejected": rejected, + }, + "reconciliation": { + "candidate_count": len(raw_paths), + "selected_count": len(keyed), + "rejected_count": len(rejected), + "omitted_count": 0, + }, + "envelope": envelope, + "privacy": {"absolute_paths_included": False}, + "recheck": {"command": "index route --root ROOT --path PATH --json"}, + } + + +def cmd_route(args) -> int: + try: + payload = build_route( + args.root, + paths=args.paths, + token_budget=args.budget, + hops=args.hops, + ) + except ValueError as exc: + raise SystemExit(str(exc)) from exc + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print( + f"route status={payload['status']} " + f"selected={payload['reconciliation']['selected_count']} " + f"rejected={payload['reconciliation']['rejected_count']}" + ) + return 0 if payload["status"] == "MATCH" else 2 + + +def call_route(args: dict, *, response_id=None) -> dict: + if not isinstance(args, dict): + raise ValueError("route arguments must be an object") + allowed = {"root", "paths", "budget", "hops"} + if set(args) - allowed: + raise ValueError("unexpected route arguments") + root = args.get("root") + paths = args.get("paths") + if not isinstance(root, str) or not root.strip(): + raise ValueError("root must be a non-empty string") + if ( + not isinstance(paths, list) + or not paths + or any(not isinstance(item, str) for item in paths) + ): + raise ValueError("paths must be a non-empty list of strings") + budget = args.get("budget", 1200) + if type(budget) is not int or budget < 1: + raise ValueError("budget must be a positive integer") + hops = args.get("hops") + if hops is not None and (type(hops) is not int or hops < 0): + raise ValueError("hops must be a non-negative integer") + return build_route( + Path(root), + paths=paths, + token_budget=budget, + hops=hops, + ) + + +def tool_definition() -> dict: + return { + "name": "index.route", + "description": ( + "Build a receipt-backed context envelope for explicit repository paths " + "without discovering unrelated workspace repositories." + ), + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "root": {"type": "string", "minLength": 1}, + "paths": { + "type": "array", + "minItems": 1, + "items": {"type": "string"}, + }, + "budget": {"type": "integer", "minimum": 1, "default": 1200}, + "hops": {"type": "integer", "minimum": 0}, + }, + "required": ["root", "paths"], + }, + } diff --git a/client-plugin/server/src/index_graph/router.py b/client-plugin/server/src/index_graph/router.py new file mode 100644 index 0000000..96babc2 --- /dev/null +++ b/client-plugin/server/src/index_graph/router.py @@ -0,0 +1,111 @@ +"""Emit a deterministic, evidence-carrying workspace router (CLAUDE.md / AGENTS.md). + +Derived from the dependency graph and the docs atlas; re-runnable, never hand-edited. +This replaces the index.md + read-first + brief that developers maintain by hand: a +model opening the workspace reads one map that says where things live, what is an entry +point, what is the core, and which docs describe what. Every line traces to a graph fact. +""" +from __future__ import annotations + + +def render_router(pack: dict, *, max_docs: int = 500, max_deps: int = 30) -> str: + relations = [r for r in pack.get("relations", []) if not r.get("external")] + roles = pack.get("roles", {}) + repos = sorted(r["name"] for r in pack.get("repos", [])) + deps_out: dict[str, dict[str, str]] = {} + deps_in: dict[str, set[str]] = {} + for r in relations: + frm, to = r.get("from"), r.get("to") + if to: + deps_out.setdefault(frm, {})[to] = _relation_label(r) + deps_in.setdefault(to, set()).add(frm) + + L = ["# Workspace map", "", + "Generated by `index` from evidence. Re-run `index router --root .` to refresh; " + "do not hand-edit.", ""] + + entry = [n for n in repos if "entrypoint" in roles.get(n, [])] + if entry: + L.append("## Entry points") + for n in entry: + out = _deps_label(deps_out.get(n, {}), max_items=max_deps) or "nothing internal" + L.append(f"- `{n}` starts here; depends on {out}") + L.append("") + + hubs = [n for n in repos if "hub" in roles.get(n, [])] + if hubs: + L.append("## Core (most depended-on, change with care)") + for n in hubs: + used = ", ".join(sorted(deps_in.get(n, []))) or "nothing" + L.append(f"- `{n}` is used by {used}") + L.append("") + + L.append("## Where things live") + for n in repos: + rs = ", ".join(roles.get(n, [])) or "unclassified" + out = _deps_label(deps_out.get(n, {}), max_items=max_deps) + dep = f"; depends on {out}" if out else "" + L.append(f"- `{n}` ({rs}){dep}") + L.append("") + + describes = sorted((e["from"], e["to"]) for e in pack.get("knowledge_edges", []) + if e.get("type") == "describes") + if describes: + L.append("## Docs") + capped = describes[:max(0, max_docs)] + for doc, repo in capped: + L.append(f"- `{doc}` describes `{repo}`") + omitted = len(describes) - len(capped) + if omitted > 0: + L.append( + f"- ... {omitted} doc edge(s) omitted from this router view; " + "run `index atlas --json` for the full evidence pack" + ) + L.append("") + + L.extend(_deep_dives(pack.get("repo_dirs", {}), repos)) + return "\n".join(L).rstrip() + "\n" + + +def _deep_dives(repo_dirs: dict, repos: list[str]) -> list[str]: + """Point an agent at the verified per-repo wiki, so the workspace map is + the entry to a deeper, re-checkable view rather than the whole story.""" + if not repo_dirs: + return [] + lines = ["## Per-repo deep dives", + "For a verified per-repo wiki (pages derived from the module graph, " + "sealed and commit-pinned), run:"] + for name in repos: + target = repo_dirs.get(name, name) + lines.append(f"- `{name}`: `index wiki --root {target}`") + lines.append("") + return lines + + +def _deps_label(deps: dict[str, str], *, max_items: int) -> str: + items = sorted(deps.items()) + labels = [label for _name, label in items[:max(0, max_items)]] + omitted = len(items) - len(labels) + if omitted > 0: + labels.append(f"... {omitted} more") + return ", ".join(labels) + + +def _relation_label(relation: dict) -> str: + to = str(relation.get("to") or "") + evidence = _first_signal(relation.get("signals")) + return f"{to} [{evidence}]" if evidence else to + + +def _first_signal(value: object) -> str: + if not isinstance(value, list): + return "" + for signal in value: + if not isinstance(signal, dict): + continue + file = signal.get("file") + if not isinstance(file, str) or not file: + continue + line = signal.get("line") + return f"{file}:{line}" if isinstance(line, int) and line > 0 else file + return "" diff --git a/client-plugin/server/src/index_graph/router_inventory.py b/client-plugin/server/src/index_graph/router_inventory.py new file mode 100644 index 0000000..2504fc3 --- /dev/null +++ b/client-plugin/server/src/index_graph/router_inventory.py @@ -0,0 +1,310 @@ +"""Operation-local router inventory for shared traversal work.""" +from __future__ import annotations + +import os +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from time import perf_counter + +from .config import Config, load_config +from .graph.walk import ( + DirectoryMembershipSnapshot, + EXCLUDE_DIRS, + PreloadedFileListing, + directory_membership_from_walk, +) +from .knowledge.docs import _MD_SUFFIXES +from .scan import ( + ScanBudget, + ScanBudgetExceeded, + enforce_interactive_repo_limit, + repo_key_map, + _warn, +) + + +class RouterInventoryInterrupted(RuntimeError): + """Raised when inventory traversal is cooperatively interrupted.""" + + def __init__(self, *, root: Path, path: Path): + self.root = Path(root) + self.path = Path(path) + super().__init__(f"router inventory interrupted at {self.path.name!r}") + + +@dataclass(frozen=True) +class RouterInventory: + root: Path + repo_paths: dict[str, Path] + repo_dirs: dict[str, str] + repo_file_lists: dict[str, PreloadedFileListing] + router_doc_paths: tuple[Path, ...] + skipped: tuple[str, ...] + stats: dict[str, int] + + +def _relative(path: Path, root: Path) -> str: + try: + return path.relative_to(root).as_posix() or "." + except ValueError: + return path.name + + +def _rel_to_root(root: Path, path: Path) -> str: + rel = path.relative_to(root).as_posix() + return "" if rel == "." else rel + + +def _is_relative_to(path: Path, base: Path) -> bool: + try: + path.relative_to(base) + except ValueError: + return False + return True + + +def _walk_ancestors(path: Path, root: Path): + current = path + while True: + yield current + if current == root: + return + parent = current.parent + if parent == current: + return + current = parent + + +def _nearest_ancestor(path: Path, candidates: set[Path], root: Path) -> Path | None: + for current in _walk_ancestors(path, root): + if current in candidates: + return current + return None + + +def _inside_config_prune(path: Path, root: Path, prune: frozenset[str]) -> bool: + try: + parts = path.relative_to(root).parts + except ValueError: + return False + return any(part in prune for part in parts) + + +def _blocked_by_repo_boundary(repo: Path, root: Path, repo_markers: set[Path], config: Config) -> bool: + if config.descend_into_repos: + return False + for marker in repo_markers: + if marker in {repo, root}: + continue + if _is_relative_to(repo, marker): + return True + return False + + +def _discover_repo_paths( + root: Path, + repo_markers: set[Path], + config: Config, + *, + budget: ScanBudget, + skipped: list[str], +) -> dict[str, Path]: + repos = [ + repo + for repo in repo_markers + if not _inside_config_prune(repo, root, config.prune) + and not _blocked_by_repo_boundary(repo, root, repo_markers, config) + ] + if budget.exhausted: + raise ScanBudgetExceeded(root=root, budget=budget, repo_count=len(repos), skipped=skipped) + repos = sorted(set(repos), key=lambda path: path.relative_to(root).as_posix().lower()) + keyed = repo_key_map(root, repos, include_root_repo=config.include_root_repo) + enforce_interactive_repo_limit(len(keyed), budget_ms=budget.budget_ms) + return keyed + + +def _assign_repo_file_lists( + root: Path, + files: list[Path], + directory_snapshots: dict[Path, DirectoryMembershipSnapshot], + repo_paths: dict[str, Path], + repo_markers: set[Path], + unreadable_dirs: list[Path], +) -> tuple[dict[str, PreloadedFileListing], int]: + repo_by_root = {path: name for name, path in repo_paths.items()} + repo_roots = set(repo_by_root) + physical_roots = { + name: directory_snapshots[path].path for name, path in repo_paths.items() + } + physical_markers = {directory_snapshots[path].path for path in repo_markers} + buckets: dict[str, list[Path]] = {name: [] for name in repo_paths} + seal_buckets: dict[str, dict[Path, DirectoryMembershipSnapshot]] = { + name: {} for name in repo_paths + } + repo_error_names: set[str] = set() + + for error_dir in unreadable_dirs: + owner = _nearest_ancestor(error_dir, repo_roots, root) + if owner is None: + continue + boundary = _nearest_ancestor(error_dir, repo_markers, root) + if boundary is None or boundary == owner: + repo_error_names.add(repo_by_root[owner]) + + for path in files: + owner = _nearest_ancestor(path.parent, repo_roots, root) + if owner is None: + continue + boundary = _nearest_ancestor(path.parent, repo_markers, root) + if boundary is not None and boundary != owner: + continue + name = repo_by_root[owner] + if name not in repo_error_names: + physical_path = directory_snapshots[path.parent].path / path.name + physical_owner = _nearest_ancestor(physical_path.parent, physical_markers, physical_roots[name]) + if (physical_owner == physical_roots[name] + and _is_relative_to(physical_path, physical_roots[name])): + buckets[name].append(physical_path) + + for directory, snapshot in directory_snapshots.items(): + owner = _nearest_ancestor(directory, repo_roots, root) + if owner is None: + continue + boundary = _nearest_ancestor(directory, repo_markers, root) + if boundary is None or boundary == owner or directory == boundary: + name = repo_by_root[owner] + physical_boundary = _nearest_ancestor(snapshot.path, physical_markers, physical_roots[name]) + if (name not in repo_error_names + and _is_relative_to(snapshot.path, physical_roots[name]) + and physical_boundary in {physical_roots[name], snapshot.path}): + seal_buckets[name][directory] = snapshot + + for marker in repo_markers: + parent_owner = _nearest_ancestor(marker.parent, repo_roots, root) + if parent_owner is None or parent_owner == marker: + continue + parent_name = repo_by_root[parent_owner] + snapshot = directory_snapshots.get(marker) + if (snapshot is not None and parent_name not in repo_error_names + and _is_relative_to(snapshot.path, physical_roots[parent_name])): + seal_buckets[parent_name][marker] = snapshot + + return { + name: PreloadedFileListing( + files=tuple(paths), + captured_root=physical_roots[name], + directory_snapshots=tuple( + snapshot + for _path, snapshot in sorted( + seal_buckets[name].items(), + key=lambda item: _relative(item[0], root), + ) + ), + ) + for name, paths in buckets.items() + if name not in repo_error_names + }, len(repo_error_names) + + +def build_router_inventory( + root: Path | str, + *, + config: Config | None = None, + budget_ms: int | None = None, + checkpoint: Callable[[Path], bool] | None = None, +) -> RouterInventory: + """Walk once and derive router docs plus graph-scoped repo file listings. + + This is a traversal cache only. Source freshness continues to come from the + exact bytes read by graph fingerprints and resolvers after this inventory is + handed to `build_graph`. + """ + root = Path(root).resolve() + config = config or load_config(None, root) + budget = ScanBudget(budget_ms) + files: list[Path] = [] + doc_paths: list[Path] = [] + repo_markers: set[Path] = set() + skipped: list[str] = [] + unreadable_dirs: list[Path] = [] + directory_snapshots: dict[Path, DirectoryMembershipSnapshot] = {} + stats = { + "physical_walks": 1, + "physical_dirs": 0, + "physical_files": 0, + "physical_file_bytes": 0, + "physical_stat_unreadable": 0, + "traversal_ms": 0, + "repo_count": 0, + "router_doc_paths": 0, + "repo_file_paths": 0, + "repo_inventory_fallbacks": 0, + } + + def onerror(exc: OSError) -> None: + raw = getattr(exc, "filename", None) + path = Path(raw) if raw else root + if not path.is_absolute(): + path = root / path + unreadable_dirs.append(path) + skipped.append(str(raw or path)) + _warn(f"warning: skipped unreadable directory during router inventory: {exc}") + + started = perf_counter() + for dirpath, dirnames, filenames in os.walk(root, onerror=onerror): + # Keep the workspace spelling: resolving a junction can move this path + # outside root and break relative repository keys and document routes. + current = Path(dirpath) + if checkpoint is not None and not checkpoint(current): + skipped.append(f"router-inventory-checkpoint-rejected:{current}") + dirnames[:] = [] + raise RouterInventoryInterrupted(root=root, path=current) + if budget.budget_ms > 0 and not budget.checkpoint(current): + skipped.append(f"scan-budget-exhausted:{current}") + _warn(f"warning: router inventory budget exhausted at {current}") + dirnames[:] = [] + break + + stats["physical_dirs"] += 1 + directory_snapshots[current] = directory_membership_from_walk(current, dirnames, filenames) + if ".git" in dirnames or ".git" in filenames: + repo_markers.add(current) + + dirnames[:] = sorted((name for name in dirnames if name not in EXCLUDE_DIRS), key=str.lower) + for filename in sorted(filenames): + path = current / filename + files.append(path) + stats["physical_files"] += 1 + try: + stats["physical_file_bytes"] += path.stat().st_size + except OSError: + stats["physical_stat_unreadable"] += 1 + if filename.endswith(_MD_SUFFIXES): + doc_paths.append(path) + stats["traversal_ms"] = int((perf_counter() - started) * 1000) + + repo_paths = _discover_repo_paths(root, repo_markers, config, budget=budget, skipped=skipped) + repo_dirs = {name: _rel_to_root(root, path) for name, path in repo_paths.items()} + repo_file_lists, fallback_count = _assign_repo_file_lists( + root, + files, + directory_snapshots, + repo_paths, + repo_markers, + unreadable_dirs, + ) + stats["repo_count"] = len(repo_paths) + stats["router_doc_paths"] = len(doc_paths) + stats["repo_file_paths"] = sum(len(listing.files) for listing in repo_file_lists.values()) + stats["repo_inventory_fallbacks"] = fallback_count + + return RouterInventory( + root=root, + repo_paths=repo_paths, + repo_dirs=repo_dirs, + repo_file_lists=repo_file_lists, + router_doc_paths=tuple(sorted(doc_paths, key=lambda path: _relative(path, root))), + skipped=tuple(skipped), + stats=stats, + ) diff --git a/client-plugin/server/src/index_graph/router_job_surface.py b/client-plugin/server/src/index_graph/router_job_surface.py new file mode 100644 index 0000000..aaab227 --- /dev/null +++ b/client-plugin/server/src/index_graph/router_job_surface.py @@ -0,0 +1,78 @@ +"""Shared argument contract for the local router-job CLI and MCP adapters.""" +from __future__ import annotations + +import json +from pathlib import Path + +from . import router_jobs + +_OPERATIONS = { + "status": "read_router_job_status", + "result": "read_router_job_result", + "cancel": "cancel_router_job", + "resume": "resume_router_job", +} + + +def call_router_job(action: str, args: dict) -> dict: + if not isinstance(args, dict): + raise ValueError("router job arguments must be an object") + allowed = {"root", "max_docs", "budget_ms", "no_cache"} if action == "start" else {"job_id"} + if set(args) - allowed: + raise ValueError("unexpected router job arguments") + if action == "start": + root = args.get("root") + if not isinstance(root, str) or not root.strip(): + raise ValueError("root must be a non-empty string") + values = {key: args.get(key, default) for key, default in + (("max_docs", 500), ("budget_ms", 0))} + for key, value in values.items(): + if type(value) is not int or value < 0: + raise ValueError(f"{key} must be a non-negative integer") + no_cache = args.get("no_cache", False) + if type(no_cache) is not bool: + raise ValueError("no_cache must be a boolean") + return router_jobs.start_router_job(Path(root), **values, use_cache=not no_cache) + if action not in _OPERATIONS: + raise ValueError("unknown router job operation") + job_id = args.get("job_id") + if not isinstance(job_id, str) or not job_id: + raise ValueError("job_id must be a non-empty string") + return getattr(router_jobs, _OPERATIONS[action])(job_id) + + +def cmd_router_job(args) -> int: + payload = ({"root": str(args.root), "max_docs": args.max_docs, + "budget_ms": args.budget_ms, "no_cache": args.no_cache} + if args.action == "start" else {"job_id": args.job_id}) + try: + receipt = call_router_job(args.action, payload) + except (OSError, ValueError, router_jobs.RouterJobError) as exc: + print(json.dumps({"schema": "index.router-job-error/v1", "status": "failed", + "error_type": type(exc).__name__, "message": str(exc)})) + return 1 + print(json.dumps(receipt, indent=2, sort_keys=True)) + return 1 if receipt.get("status") == "failed" else 0 + + +def tool_definitions() -> list[dict]: + start_properties = { + "root": {"type": "string", "minLength": 1}, + "max_docs": {"type": "integer", "minimum": 0, "default": 500}, + "budget_ms": {"type": "integer", "minimum": 0, "default": 0, + "description": "Discovery budget; zero permits a complete background scan."}, + "no_cache": {"type": "boolean", "default": False}, + } + descriptions = { + "start": "Start a durable local router build and return a job receipt promptly. State and output remain private local files.", + "status": "Read current router job progress and recovery state.", + "result": "Retrieve router text only from a complete, validated local job result.", + "cancel": "Request cooperative cancellation; acknowledgment is reported separately.", + "resume": "Retry a stopped router job with its original request and fresh discovery.", + } + return [{"name": f"index.router.job.{action}", "description": description, + "inputSchema": {"type": "object", "additionalProperties": False, + "properties": start_properties if action == "start" else { + "job_id": {"type": "string", "minLength": 1}}, + "required": ["root"] if action == "start" else ["job_id"]}} + for action, description in descriptions.items()] diff --git a/client-plugin/server/src/index_graph/router_job_telemetry.py b/client-plugin/server/src/index_graph/router_job_telemetry.py new file mode 100644 index 0000000..6a95eaf --- /dev/null +++ b/client-plugin/server/src/index_graph/router_job_telemetry.py @@ -0,0 +1,50 @@ +"""Private router-job telemetry helpers.""" +from __future__ import annotations + +from time import perf_counter +from typing import Any, Callable + + +class RouterJobTelemetry: + def __init__(self, started: float, append_event: Callable[[dict[str, Any]], None]) -> None: + self._started = started + self._append_event = append_event + self.phase_timings_ms: dict[str, int] = {} + self.graph_cache: dict[str, object] | None = None + self.router_inventory: dict[str, object] | None = None + self.router_docs: dict[str, object] | None = None + self._events: list[dict[str, Any]] = [] + + def status_fields(self) -> dict[str, Any]: + fields: dict[str, Any] = {} + if self.phase_timings_ms: + fields["phase_timings_ms"] = dict(self.phase_timings_ms) + if self.graph_cache is not None: + fields["graph_cache"] = dict(self.graph_cache) + if self.router_inventory is not None: + fields["router_inventory"] = dict(self.router_inventory) + if self.router_docs is not None: + fields["router_docs"] = dict(self.router_docs) + return fields + + def record_timing(self, step: str, step_started: float, **counts: Any) -> None: + self.record_duration(step, int((perf_counter() - step_started) * 1000), **counts) + + def record_duration(self, step: str, duration_ms: int, **counts: Any) -> None: + self.phase_timings_ms[step] = duration_ms + event: dict[str, Any] = { + "phase": "timing", + "step": step, + "duration_ms": duration_ms, + "elapsed_ms": int((perf_counter() - self._started) * 1000), + } + for key, value in counts.items(): + if key in {"content", "repo_path"}: + continue + if isinstance(value, (bool, int, float, str)) or value is None: + event[key] = value + self._events.append(event) + + def flush(self) -> None: + while self._events: + self._append_event(self._events.pop(0)) diff --git a/client-plugin/server/src/index_graph/router_job_utils.py b/client-plugin/server/src/index_graph/router_job_utils.py new file mode 100644 index 0000000..f13b99b --- /dev/null +++ b/client-plugin/server/src/index_graph/router_job_utils.py @@ -0,0 +1,51 @@ +"""Small private helpers for durable router jobs.""" +from __future__ import annotations + +import hashlib +import json +from datetime import datetime +from pathlib import Path +from typing import Any + + +def now() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def root_hash(root: Path) -> str: + return hashlib.sha256(str(root.resolve()).encode("utf-8", "surrogateescape")).hexdigest()[:16] + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def sha256_text(value: str) -> str: + return sha256_bytes(value.encode("utf-8", "surrogateescape")) + + +def canonical_json(data: dict[str, Any]) -> str: + return json.dumps(data, sort_keys=True, separators=(",", ":"), default=str) + + +def request_sha256(request: dict[str, Any]) -> str: + return sha256_text(canonical_json(dict(request))) + + +def config_sha256(root: Path) -> str: + parts: list[list[str]] = [] + for name in (".index.toml", ".repomap.toml"): + path = root / name + if not path.exists(): + parts.append([name, "missing"]) + continue + try: + parts.append([name, sha256_bytes(path.read_bytes())]) + except OSError: + parts.append([name, "unreadable"]) + return sha256_text(canonical_json({"root": str(root.resolve()), "config": parts})) + + +def safe_error_message(exc: BaseException) -> str: + text = str(exc) or type(exc).__name__ + return " ".join(text.split())[:500] diff --git a/client-plugin/server/src/index_graph/router_jobs.py b/client-plugin/server/src/index_graph/router_jobs.py new file mode 100644 index 0000000..53983fc --- /dev/null +++ b/client-plugin/server/src/index_graph/router_jobs.py @@ -0,0 +1,1086 @@ +"""Durable local jobs for complete workspace router builds. + +This module is intentionally independent from the CLI/MCP wiring. It owns the +private local job state and runs the existing router pipeline in a child process +so timeout-bound callers can start, poll, and fetch complete results without +receiving partial router content. +""" +from __future__ import annotations + +import json +import os +import subprocess +import stat +import sys +import threading +import time +import uuid +from contextlib import contextmanager +from dataclasses import asdict +from pathlib import Path +from time import perf_counter +from typing import Any, BinaryIO + +from . import __version__ +from .graph.build import GraphProgress, build_graph +from .router_job_telemetry import RouterJobTelemetry +from .router_job_utils import ( + config_sha256 as _config_sha256, + now as _now, + request_sha256 as _request_sha256, + root_hash as _root_hash, + safe_error_message as _safe_error_message, + sha256_text as _sha256_text, +) + +_STATUS_SCHEMA = "index.router-job-status/v1" +_RESULT_SCHEMA = "index.router-job-result/v1" +_REQUEST_SCHEMA = "index.router-job-request/v1" +_EVENT_SCHEMA = "index.router-job-event/v1" +_CANCEL_SCHEMA = "index.router-job-cancel/v1" +_CANCEL_FILE = "cancel.request" +_STATE_LOCK_FILE = "state.lock" +_STATUS_FILE = "status.json" +_REQUEST_FILE = "request.json" +_RESULT_FILE = "router.md" +_EVENTS_FILE = "events.jsonl" +_LEASE_FILE = "worker.lock" +_HEARTBEAT_FILE = "worker.heartbeat.json" +_LEASE_SCHEMA = "index.router-job-lease/v1" +_HEARTBEAT_SCHEMA = "index.router-job-heartbeat/v1" +_LOCK_BYTE_COUNT = 1 +_WINDOWS_REPARSE_ATTRIBUTE = 0x400 +_STATE_LOCKS = threading.local() +_STATE_THREAD_LOCKS: dict[str, threading.RLock] = {} +_STATE_THREAD_LOCKS_GUARD = threading.Lock() + +class RouterJobError(ValueError): + """Raised for invalid local job operations.""" + +class RouterJobCancelled(RuntimeError): + """Internal cooperative cancellation signal.""" + + +class RouterJobLostOwnership(RuntimeError): + """Internal signal for a stale worker attempt that must not write status.""" + + +class RouterJobAlreadyActive(RuntimeError): + """Internal signal for a duplicate worker when the active lease is fresh.""" + + +def _ensure_lock_byte(fh: BinaryIO) -> None: + fh.seek(0, os.SEEK_END) + if fh.tell() == 0: + fh.seek(0) + fh.write(b"0") + fh.flush() + fh.seek(0) + + +def _lock_handle(fh: BinaryIO, *, blocking: bool) -> None: + if os.name == "nt": + import msvcrt + + mode = msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK + msvcrt.locking(fh.fileno(), mode, _LOCK_BYTE_COUNT) + return + import fcntl + + flags = fcntl.LOCK_EX + if not blocking: + flags |= fcntl.LOCK_NB + fcntl.flock(fh.fileno(), flags) + + +def _unlock_handle(fh: BinaryIO) -> None: + fh.seek(0) + if os.name == "nt": + import msvcrt + + try: + msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, _LOCK_BYTE_COUNT) + except OSError: + pass + return + import fcntl + + fcntl.flock(fh.fileno(), fcntl.LOCK_UN) + + +@contextmanager +def _locked_file(path: Path, *, blocking: bool = True): + path.parent.mkdir(parents=True, exist_ok=True) + fh = path.open("a+b") + locked = False + try: + _ensure_lock_byte(fh) + _lock_handle(fh, blocking=blocking) + locked = True + yield fh + finally: + if locked: + _unlock_handle(fh) + fh.close() + + +@contextmanager +def _state_lock(job_dir: Path): + lock_path = job_dir / _STATE_LOCK_FILE + key = str(lock_path.resolve()) + held = getattr(_STATE_LOCKS, "held", None) + if held is None: + held = set() + _STATE_LOCKS.held = held + if key in held: + yield + return + with _STATE_THREAD_LOCKS_GUARD: + thread_lock = _STATE_THREAD_LOCKS.setdefault(key, threading.RLock()) + with thread_lock: + with _locked_file(lock_path): + held.add(key) + try: + yield + finally: + held.remove(key) + + +def _heartbeat_stale_seconds() -> float: + raw = os.environ.get("INDEX_ROUTER_JOB_HEARTBEAT_STALE_SECONDS", "60") + try: + return max(1.0, float(raw)) + except ValueError: + return 60.0 + + +def _is_terminal(status: dict[str, Any]) -> bool: + return status.get("status") in {"complete", "failed", "cancelled"} + + +def default_job_root() -> Path: + raw = os.environ.get("INDEX_ROUTER_JOB_DIR") + if raw: + return Path(raw) + base = os.environ.get("LOCALAPPDATA") + if base: + return Path(base) / "index_graph" / "router-jobs" + return Path.home() / ".cache" / "index_graph" / "router-jobs" + + +def _job_root(job_root: Path | str | None = None) -> Path: + return Path(job_root) if job_root is not None else default_job_root() + + +def _validate_job_id(job_id: str) -> str: + try: + parsed = uuid.UUID(str(job_id)) + except ValueError as exc: + raise RouterJobError("invalid router job id") from exc + return str(parsed) + + +def _job_dir(job_id: str, job_root: Path | str | None = None) -> Path: + return _job_root(job_root) / _validate_job_id(job_id) + + +def _atomic_write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.{uuid.uuid4().hex}.tmp") + with tmp.open("w", encoding="utf-8", newline="\n") as fh: + fh.write(text) + tmp.replace(path) + + +def _atomic_write_json(path: Path, data: dict[str, Any]) -> None: + _atomic_write_text(path, json.dumps(data, indent=2, sort_keys=True) + "\n") + + +def _read_json(path: Path) -> dict[str, Any]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise RouterJobError(f"cannot read router job state: {path.name}") from exc + except json.JSONDecodeError as exc: + raise RouterJobError(f"invalid router job state: {path.name}") from exc + if not isinstance(data, dict): + raise RouterJobError(f"invalid router job state: {path.name}") + return data + + +def _read_request(job_dir: Path) -> dict[str, Any]: + data = _read_json(job_dir / _REQUEST_FILE) + if data.get("schema") != _REQUEST_SCHEMA: + raise RouterJobError("invalid router job request schema") + if data.get("job_id") != job_dir.name: + raise RouterJobError("router job request id mismatch") + return data + + +def _read_status_file_unlocked(job_dir: Path) -> dict[str, Any]: + data = _read_json(job_dir / _STATUS_FILE) + if data.get("schema") != _STATUS_SCHEMA: + raise RouterJobError("invalid router job status schema") + if data.get("job_id") != job_dir.name: + raise RouterJobError("router job status id mismatch") + return data + + +def _read_status_file(job_dir: Path) -> dict[str, Any]: + with _state_lock(job_dir): + return _read_status_file_unlocked(job_dir) + + +def _read_json_if_present(path: Path) -> dict[str, Any] | None: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError): + return None + return data if isinstance(data, dict) else None + + +def _write_status_unlocked(job_dir: Path, status: dict[str, Any]) -> dict[str, Any]: + status = dict(status) + status["schema"] = _STATUS_SCHEMA + status["job_id"] = job_dir.name + status["updated_at"] = _now() + _atomic_write_json(job_dir / _STATUS_FILE, status) + return status + + +def _write_status(job_dir: Path, status: dict[str, Any]) -> dict[str, Any]: + with _state_lock(job_dir): + return _write_status_unlocked(job_dir, status) + + +def _append_event(job_dir: Path, event: dict[str, Any]) -> None: + rec = {"schema": _EVENT_SCHEMA, "job_id": job_dir.name, "recorded_at": _now(), **event} + with (job_dir / _EVENTS_FILE).open("a", encoding="utf-8") as fh: + fh.write(json.dumps(rec, sort_keys=True, separators=(",", ":")) + "\n") + + +def _write_heartbeat(job_dir: Path, run_token: str, *, started_epoch: float) -> None: + payload = { + "schema": _HEARTBEAT_SCHEMA, + "job_id": job_dir.name, + "run_token": run_token, + "pid": os.getpid(), + "started_epoch": started_epoch, + "updated_epoch": time.time(), + } + _atomic_write_json(job_dir / _HEARTBEAT_FILE, payload) + + +def _start_heartbeat(job_dir: Path, run_token: str, *, started_epoch: float) -> tuple[threading.Event, threading.Thread]: + stop = threading.Event() + + def beat() -> None: + while not stop.wait(1.0): + try: + _write_heartbeat(job_dir, run_token, started_epoch=started_epoch) + except OSError: + pass + + _write_heartbeat(job_dir, run_token, started_epoch=started_epoch) + thread = threading.Thread(target=beat, name="index-router-job-heartbeat", daemon=True) + thread.start() + return stop, thread + + +def _worker_lock_is_held(job_dir: Path) -> bool: + lock_path = job_dir / _LEASE_FILE + if not lock_path.exists(): + return False + try: + with _locked_file(lock_path, blocking=False): + return False + except OSError: + return True + + +def _active_worker(status: dict[str, Any], job_dir: Path) -> bool: + run_token = status.get("run_token") + if not isinstance(run_token, str) or not run_token: + return False + return _worker_lock_is_held(job_dir) + + +def _acquire_lease(job_dir: Path, run_token: str) -> BinaryIO: + lease_path = job_dir / _LEASE_FILE + payload = { + "schema": _LEASE_SCHEMA, + "job_id": job_dir.name, + "run_token": run_token, + "pid": os.getpid(), + "started_epoch": time.time(), + } + lock: BinaryIO | None = None + try: + lock = (job_dir / _LEASE_FILE).open("a+b") + _ensure_lock_byte(lock) + _lock_handle(lock, blocking=False) + except OSError as exc: + if lock is not None: + lock.close() + raise RouterJobAlreadyActive("router job already has an active worker lock") from exc + lock.seek(0) + lock.truncate() + lock.write(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n") + lock.flush() + return lock + + +def _release_lease(job_dir: Path, run_token: str, lock: BinaryIO | None = None) -> None: + if lock is not None: + try: + _unlock_handle(lock) + finally: + lock.close() + + +def _cancel_requested(job_dir: Path, run_token: str | None = None) -> bool: + path = job_dir / _CANCEL_FILE + if not path.exists(): + return False + data = _read_json_if_present(path) + if data is None: + return True + marker_token = data.get("run_token") + return not marker_token or marker_token == run_token + + +def _recent_worker_start(status: dict[str, Any]) -> bool: + try: + started = float(status.get("worker_started_epoch")) + except (TypeError, ValueError): + return False + return time.time() - started <= _heartbeat_stale_seconds() + + +def _fail_status( + job_dir: Path, + status: dict[str, Any], + *, + error_type: str, + message: str, +) -> dict[str, Any]: + failed = dict(status) + failed["status"] = "failed" + failed["phase"] = "failed" + failed["error_type"] = error_type + failed["message"] = message + failed["result_available"] = False + failed["finished_at"] = failed.get("finished_at") or _now() + return _write_status_unlocked(job_dir, failed) + + +def _result_file_is_unsafe(path: Path) -> bool: + try: + info = os.lstat(path) + except FileNotFoundError: + return False + if stat.S_ISLNK(info.st_mode): + return True + if getattr(info, "st_file_attributes", 0) & _WINDOWS_REPARSE_ATTRIBUTE: + return True + return not stat.S_ISREG(info.st_mode) + + +def _read_regular_result_text(job_dir: Path) -> str: + result_path = job_dir / _RESULT_FILE + if _result_file_is_unsafe(result_path): + raise RouterJobError("router job result path is a link, reparse point, or non-regular file") + if not result_path.is_file(): + raise FileNotFoundError("router job result is missing") + resolved_job = job_dir.resolve() + resolved_result = result_path.resolve() + if resolved_result.parent != resolved_job: + raise RouterJobError("router job result path escapes its job directory") + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + fd = os.open(str(result_path), flags) + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode): + raise RouterJobError("router job result is not a regular file") + with os.fdopen(fd, "r", encoding="utf-8", errors="surrogateescape", newline=None, closefd=False) as fh: + return fh.read() + finally: + os.close(fd) + + +def _base_status(job_id: str, request: dict[str, Any], *, attempt: int) -> dict[str, Any]: + return { + "schema": _STATUS_SCHEMA, + "job_id": job_id, + "tool_version": __version__, + "source_version": __version__, + "root": request["root"], + "root_sha256_prefix": request["root_sha256_prefix"], + "max_docs": request["max_docs"], + "budget_ms": request["budget_ms"], + "use_cache": request["use_cache"], + "executor": request["executor"], + "status": "running", + "phase": "queued", + "completed_repos": 0, + "total_repos": None, + "attempt": attempt, + "run_token": None, + "pid": None, + "worker_started_epoch": None, + "request_sha256": _request_sha256(request), + "config_sha256": None, + "result_sha256": None, + "result_bytes": None, + "created_at": request["created_at"], + "started_at": None, + "updated_at": _now(), + "finished_at": None, + "result_available": False, + } + + +def create_router_job( + root: Path | str, + *, + job_root: Path | str | None = None, + max_docs: int = 500, + budget_ms: int = 0, + use_cache: bool = True, + executor: str = "process", +) -> dict[str, Any]: + """Create local durable state for a complete router build without spawning it.""" + root_path = Path(root).resolve() + if max_docs < 0: + raise RouterJobError("max_docs must be non-negative") + if budget_ms < 0: + raise RouterJobError("budget_ms must be non-negative") + if executor not in {"thread", "process"}: + raise RouterJobError("executor must be 'thread' or 'process'") + job_id = str(uuid.uuid4()) + job_dir = _job_root(job_root) / job_id + job_dir.mkdir(parents=True, exist_ok=False) + created = _now() + request = { + "schema": _REQUEST_SCHEMA, + "job_id": job_id, + "tool_version": __version__, + "source_version": __version__, + "root": str(root_path), + "root_sha256_prefix": _root_hash(root_path), + "max_docs": int(max_docs), + "budget_ms": int(budget_ms), + "use_cache": bool(use_cache), + "executor": executor, + "created_at": created, + } + _atomic_write_json(job_dir / _REQUEST_FILE, request) + status = _base_status(job_id, request, attempt=1) + _write_status(job_dir, status) + receipt = dict(status) + receipt["job_dir"] = str(job_dir) + return receipt + + +def _spawn_worker(job_dir: Path, run_token: str) -> subprocess.Popen: + args = [sys.executable, "-m", "index_graph.router_jobs", "_worker", str(job_dir), run_token] + flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + stdout_path = job_dir / "worker.stdout.log" + stderr_path = job_dir / "worker.stderr.log" + stdout = stdout_path.open("ab") + stderr = stderr_path.open("ab") + try: + return subprocess.Popen( + args, + stdin=subprocess.DEVNULL, + stdout=stdout, + stderr=stderr, + cwd=str(Path.cwd()), + close_fds=True, + creationflags=flags, + ) + finally: + stdout.close() + stderr.close() + + +def start_router_job( + root: Path | str, + *, + job_root: Path | str | None = None, + max_docs: int = 500, + budget_ms: int = 0, + use_cache: bool = True, + executor: str = "process", +) -> dict[str, Any]: + """Start a router job and return a durable running receipt quickly.""" + receipt = create_router_job( + root, + job_root=job_root, + max_docs=max_docs, + budget_ms=budget_ms, + use_cache=use_cache, + executor=executor, + ) + job_dir = Path(receipt["job_dir"]) + run_token = uuid.uuid4().hex + with _state_lock(job_dir): + status = _read_status_file_unlocked(job_dir) + status["run_token"] = run_token + status["phase"] = "starting" + status["started_at"] = _now() + status["worker_started_epoch"] = None + _write_status_unlocked(job_dir, status) + try: + proc = _spawn_worker(job_dir, run_token) + except OSError as exc: + with _state_lock(job_dir): + status = _read_status_file_unlocked(job_dir) + status["status"] = "failed" + status["phase"] = "failed" + status["error_type"] = type(exc).__name__ + status["message"] = _safe_error_message(exc) + status["result_available"] = False + status["finished_at"] = _now() + written = _write_status_unlocked(job_dir, status) + written["job_dir"] = str(job_dir) + return written + with _state_lock(job_dir): + current = _read_status_file_unlocked(job_dir) + if current.get("run_token") != run_token or _is_terminal(current): + current["job_dir"] = str(job_dir) + return current + if current.get("phase") != "starting": + current["job_dir"] = str(job_dir) + return current + status = current + status["pid"] = proc.pid + status["phase"] = "started" + status["worker_started_epoch"] = time.time() + written = _write_status_unlocked(job_dir, status) + written["job_dir"] = str(job_dir) + return written + + +def _pid_running(pid: int | None) -> bool: + if not isinstance(pid, int) or pid <= 0: + return False + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def _recover_running_status(job_dir: Path, status: dict[str, Any]) -> dict[str, Any]: + if status.get("status") not in {"running", "cancellation_requested"}: + if status.get("status") == "complete" and not (job_dir / _RESULT_FILE).is_file(): + return _fail_status( + job_dir, + status, + error_type="MissingResult", + message="router job marked complete but result is missing", + ) + return status + if status.get("run_token") is None: + return status + if _active_worker(status, job_dir): + return status + if _recent_worker_start(status): + return status + if (job_dir / _RESULT_FILE).is_file(): + status = _fail_status( + job_dir, + status, + error_type="UnsealedResult", + message="router job worker exited with an unsealed result", + ) + elif status.get("status") == "cancellation_requested": + status = _fail_status( + job_dir, + status, + error_type="WorkerInterrupted", + message="router job worker stopped before acknowledging cancellation", + ) + else: + status = _fail_status( + job_dir, + status, + error_type="WorkerInterrupted", + message="router job worker lock is no longer active", + ) + return status + + +def read_router_job_status(job_id: str, *, job_root: Path | str | None = None) -> dict[str, Any]: + job_dir = _job_dir(job_id, job_root) + with _state_lock(job_dir): + status = _recover_running_status(job_dir, _read_status_file_unlocked(job_dir)) + receipt = dict(status) + receipt["job_dir"] = str(job_dir) + return receipt + + +def read_router_job_result(job_id: str, *, job_root: Path | str | None = None) -> dict[str, Any]: + status = read_router_job_status(job_id, job_root=job_root) + job_dir = Path(status["job_dir"]) + if status.get("status") != "complete": + return { + "schema": _RESULT_SCHEMA, + "job_id": status["job_id"], + "status": status.get("status"), + "phase": status.get("phase"), + "result_available": False, + "status_receipt": status, + } + with _state_lock(job_dir): + status = _read_status_file_unlocked(job_dir) + if status.get("status") != "complete": + receipt = dict(status) + receipt["job_dir"] = str(job_dir) + return { + "schema": _RESULT_SCHEMA, + "job_id": status["job_id"], + "status": status.get("status"), + "phase": status.get("phase"), + "result_available": False, + "status_receipt": receipt, + } + + def failed_result(error_type: str, message: str) -> dict[str, Any]: + failed = _fail_status(job_dir, status, error_type=error_type, message=message) + receipt = dict(failed) + receipt["job_dir"] = str(job_dir) + return { + "schema": _RESULT_SCHEMA, + "job_id": status["job_id"], + "status": "failed", + "phase": "failed", + "error_type": error_type, + "result_available": False, + "status_receipt": receipt, + } + + try: + request = _read_request(job_dir) + except RouterJobError as exc: + return failed_result("RequestSealInvalid", _safe_error_message(exc)) + expected_request_hash = status.get("request_sha256") + if not isinstance(expected_request_hash, str) or _request_sha256(request) != expected_request_hash: + return failed_result( + "RequestSealInvalid", + "router job request seal does not match complete status receipt", + ) + try: + content = _read_regular_result_text(job_dir) + except FileNotFoundError: + return failed_result("MissingResult", "router job result is missing") + except RouterJobError as exc: + return failed_result("UnsafeResult", _safe_error_message(exc)) + actual_hash = _sha256_text(content) + expected_hash = status.get("result_sha256") + if not isinstance(expected_hash, str) or actual_hash != expected_hash: + return failed_result( + "CorruptResult", + "router job result hash does not match complete status receipt", + ) + receipt = dict(status) + receipt["job_dir"] = str(job_dir) + return { + "schema": _RESULT_SCHEMA, + "job_id": status["job_id"], + "status": "complete", + "phase": "complete", + "result_available": True, + "result_sha256": actual_hash, + "request_sha256": status.get("request_sha256"), + "config_sha256": status.get("config_sha256"), + "content": content, + "status_receipt": receipt, + } + + +def cancel_router_job( + job_id: str, + *, + job_root: Path | str | None = None, + terminate: bool = False, +) -> dict[str, Any]: + job_dir = _job_dir(job_id, job_root) + with _state_lock(job_dir): + observed = _read_status_file_unlocked(job_dir) + marker = { + "schema": _CANCEL_SCHEMA, + "job_id": job_dir.name, + "run_token": observed.get("run_token"), + "attempt": observed.get("attempt"), + "requested_at": _now(), + } + _atomic_write_json(job_dir / _CANCEL_FILE, marker) + status = _read_status_file_unlocked(job_dir) + if status.get("status") == "complete": + status["message"] = "router job was already complete when cancellation was requested" + status["result_available"] = (job_dir / _RESULT_FILE).is_file() + elif _is_terminal(status): + status["message"] = "router job was already terminal when cancellation was requested" + status["result_available"] = False + elif status.get("run_token") != observed.get("run_token") or status.get("attempt") != observed.get("attempt"): + status["message"] = "router job changed attempts before cancellation could be applied" + status["result_available"] = False + else: + status["status"] = "cancellation_requested" + status["cancel_requested_at"] = marker["requested_at"] + status["result_available"] = False + status["message"] = "router job cancellation requested" + if terminate: + status["termination_supported"] = False + status["message"] = ( + "router job cancellation requested; hard termination was not attempted " + "because durable jobs require an owned worker handle, not PID-only identity" + ) + written = _write_status_unlocked(job_dir, status) + written["job_dir"] = str(job_dir) + return written + + +def resume_router_job(job_id: str, *, job_root: Path | str | None = None) -> dict[str, Any]: + job_dir = _job_dir(job_id, job_root) + with _state_lock(job_dir): + status = _recover_running_status(job_dir, _read_status_file_unlocked(job_dir)) + if status.get("status") == "complete": + status = dict(status) + status["job_dir"] = str(job_dir) + return status + if status.get("status") in {"running", "cancellation_requested"} and ( + _active_worker(status, job_dir) or _recent_worker_start(status) + ): + status = dict(status) + status["job_dir"] = str(job_dir) + return status + request = _read_request(job_dir) + try: + (job_dir / _CANCEL_FILE).unlink() + except FileNotFoundError: + pass + next_status = _base_status(job_dir.name, request, attempt=int(status.get("attempt") or 1) + 1) + next_status["phase"] = "restarting" + next_status["started_at"] = _now() + run_token = uuid.uuid4().hex + next_status["run_token"] = run_token + next_status["config_sha256"] = _config_sha256(Path(request["root"]).resolve()) + _write_status_unlocked(job_dir, next_status) + try: + proc = _spawn_worker(job_dir, run_token) + except OSError as exc: + next_status["status"] = "failed" + next_status["phase"] = "failed" + next_status["error_type"] = type(exc).__name__ + next_status["message"] = _safe_error_message(exc) + next_status["result_available"] = False + next_status["finished_at"] = _now() + written = _write_status_unlocked(job_dir, next_status) + written["job_dir"] = str(job_dir) + return written + current = _read_status_file_unlocked(job_dir) + if current.get("run_token") != run_token or _is_terminal(current): + current["job_dir"] = str(job_dir) + return current + if current.get("phase") != "restarting": + current["job_dir"] = str(job_dir) + return current + next_status = dict(current) + next_status["pid"] = proc.pid + next_status["phase"] = "started" + next_status["worker_started_epoch"] = time.time() + written = _write_status_unlocked(job_dir, next_status) + written["job_dir"] = str(job_dir) + return written + + +def run_router_job_worker(job_dir: Path | str, run_token: str | None = None) -> int: + """Run one router job in this process. Public for tests and subprocess entry.""" + job_dir = Path(job_dir).resolve() + request = _read_request(job_dir) + status = _read_status_file(job_dir) + run_token = run_token or status.get("run_token") or uuid.uuid4().hex + if not isinstance(run_token, str) or not run_token: + run_token = uuid.uuid4().hex + started = perf_counter() + worker_instance_id = uuid.uuid4().hex + + def emit(event: dict[str, Any], *, scope: str = "job", terminal: bool = False) -> None: + # Timing records may have been buffered. Stamp their emission, not their + # earlier collection time, on the same clock as all other attempt events. + _append_event(job_dir, { + **event, + "scope": scope, + "status": event["phase"] if terminal else "running", + "terminal": terminal, + "run_token": run_token, + "worker_instance_id": worker_instance_id, + "elapsed_clock": "worker_monotonic", + "elapsed_ms": int((perf_counter() - started) * 1000), + }) + + if status.get("run_token") and status.get("run_token") != run_token: + emit( + { + "phase": "failed", + "error_type": "RouterJobLostOwnership", + "message": "worker token does not match current job status", + }, + scope="attempt", terminal=True, + ) + return 1 + started_epoch = time.time() + stop_heartbeat: threading.Event | None = None + lease_lock: BinaryIO | None = None + telemetry = RouterJobTelemetry(started, lambda event: emit(event, scope="stage")) + + def update(**fields: Any) -> dict[str, Any]: + nonlocal status + with _state_lock(job_dir): + current = _read_status_file_unlocked(job_dir) + if current.get("run_token") and current.get("run_token") != run_token: + raise RouterJobLostOwnership("worker no longer owns this router job attempt") + if current.get("status") in {"complete", "failed", "cancelled"} and fields.get("status") not in { + "complete", + "failed", + "cancelled", + }: + raise RouterJobLostOwnership("worker cannot overwrite terminal router job status") + if current.get("status") == "cancellation_requested" and fields.get("status") == "running": + raise RouterJobCancelled("router job cancellation requested") + if fields.get("status") == "running" and _cancel_requested(job_dir, run_token): + raise RouterJobCancelled("router job cancellation requested") + status = { + **current, + **telemetry.status_fields(), + **fields, + "run_token": run_token, + "elapsed_ms": int((perf_counter() - started) * 1000), + } + return _write_status_unlocked(job_dir, status) + + def progress(event: GraphProgress) -> None: + payload = asdict(event) + phase = f"graph_{event.phase}" if event.phase in {"complete", "failed"} else event.phase + payload.update( + phase=phase, + graph_phase=event.phase, + graph_elapsed_ms=event.elapsed_ms, + ) + emit(payload, scope="graph") + update( + status="running", + phase=phase, + completed_repos=event.completed_repos, + total_repos=event.total_repos, + ) + if _cancel_requested(job_dir, run_token): + raise RouterJobCancelled("router job cancellation requested") + + try: + lease_lock = _acquire_lease(job_dir, run_token) + stop_heartbeat, _thread = _start_heartbeat(job_dir, run_token, started_epoch=started_epoch) + update( + status="running", + phase=status.get("phase") if status.get("phase") in {"starting", "restarting"} else "started", + pid=os.getpid(), + worker_started_epoch=started_epoch, + started_at=status.get("started_at") or _now(), + request_sha256=_request_sha256(request), + config_sha256=None, + result_sha256=None, + result_bytes=None, + result_available=False, + ) + if _cancel_requested(job_dir, run_token): + raise RouterJobCancelled("router job cancellation requested") + root = Path(request["root"]).resolve() + if not root.is_dir(): + raise FileNotFoundError(f"root not found: {root}") + config_started = perf_counter() + config_sha256 = _config_sha256(root) + telemetry.record_timing("config", config_started) + update(status="running", phase="discovering", config_sha256=config_sha256) + discovery_started = perf_counter() + from .knowledge.atlas import build_router_pack + from .knowledge.docs import discover_router_docs + from .router_inventory import build_router_inventory + from .router import render_router + + def inventory_checkpoint(_path: Path) -> bool: + if _cancel_requested(job_dir, run_token): + raise RouterJobCancelled("router job cancellation requested") + return True + + inventory = build_router_inventory( + root, + budget_ms=int(request.get("budget_ms", 0)), + checkpoint=inventory_checkpoint, + ) + paths = inventory.repo_paths + telemetry.router_inventory = { + "physical_walks": int(inventory.stats.get("physical_walks", 0)), + "physical_dirs": int(inventory.stats.get("physical_dirs", 0)), + "physical_files": int(inventory.stats.get("physical_files", 0)), + "physical_file_bytes": int(inventory.stats.get("physical_file_bytes", 0)), + "physical_stat_unreadable": int(inventory.stats.get("physical_stat_unreadable", 0)), + "traversal_ms": int(inventory.stats.get("traversal_ms", 0)), + "repo_count": len(paths), + "router_doc_paths": int(inventory.stats.get("router_doc_paths", 0)), + "repo_file_paths": int(inventory.stats.get("repo_file_paths", 0)), + "repo_inventory_fallbacks": int(inventory.stats.get("repo_inventory_fallbacks", 0)), + } + telemetry.record_timing( + "repo_discovery", + discovery_started, + total_repos=len(paths), + physical_walks=telemetry.router_inventory["physical_walks"], + physical_files=telemetry.router_inventory["physical_files"], + ) + if _cancel_requested(job_dir, run_token): + raise RouterJobCancelled("router job cancellation requested") + update(status="running", phase="building", completed_repos=0, total_repos=len(paths)) + repo_dirs = inventory.repo_dirs + graph_started = perf_counter() + graph = build_graph( + paths, + executor=str(request.get("executor") or "process"), + use_cache=bool(request.get("use_cache", True)), + on_progress=progress, + file_lists=inventory.repo_file_lists, + ) + telemetry.graph_cache = getattr(graph, "cache_summary", None) + telemetry.record_timing("graph", graph_started, total_repos=len(paths)) + telemetry.flush() + if _cancel_requested(job_dir, run_token): + raise RouterJobCancelled("router job cancellation requested") + update(status="running", phase="rendering", completed_repos=len(paths), total_repos=len(paths)) + docs_started = perf_counter() + docs_stats: dict[str, int] = {} + docs = discover_router_docs(root, paths=inventory.router_doc_paths, stats=docs_stats) + telemetry.router_docs = { + "docs": len(docs), + "traversal_ms": int(inventory.stats.get("traversal_ms", 0)), + "physical_walks": int(inventory.stats.get("physical_walks", 0)), + "row_construction_ms": int(docs_stats.get("row_construction_ms", 0)), + } + telemetry.record_timing("router_docs", docs_started, **telemetry.router_docs) + pack_started = perf_counter() + pack = build_router_pack(graph, docs, repo_dirs) + telemetry.record_timing( + "router_pack", + pack_started, + docs=len(docs), + knowledge_edges=len(pack.get("knowledge_edges", ())), + ) + render_started = perf_counter() + text = render_router(pack, max_docs=max(0, int(request.get("max_docs", 500)))) + result_bytes = len(text.encode("utf-8", "surrogateescape")) + telemetry.record_timing("render", render_started, result_bytes=result_bytes) + result_write_started = perf_counter() + result_sha256 = _sha256_text(text) + attempt_result = job_dir / f"router.{run_token}.md" + _atomic_write_text(attempt_result, text) + try: + with _state_lock(job_dir): + current = _read_status_file_unlocked(job_dir) + if current.get("run_token") != run_token: + raise RouterJobLostOwnership("worker no longer owns this router job attempt") + if current.get("status") in {"complete", "failed", "cancelled"}: + raise RouterJobLostOwnership("worker cannot overwrite terminal router job status") + if current.get("status") == "cancellation_requested" or _cancel_requested(job_dir, run_token): + raise RouterJobCancelled("router job cancellation requested") + if _result_file_is_unsafe(job_dir / _RESULT_FILE): + raise RouterJobError("router job result path is unsafe") + attempt_result.replace(job_dir / _RESULT_FILE) + telemetry.phase_timings_ms["result_write"] = int((perf_counter() - result_write_started) * 1000) + status = { + **current, + "status": "complete", + "phase": "complete", + "completed_repos": len(paths), + "total_repos": len(paths), + "result_available": True, + "result_path": str(job_dir / _RESULT_FILE), + "result_sha256": result_sha256, + "result_bytes": result_bytes, + "request_sha256": _request_sha256(request), + "config_sha256": config_sha256, + **telemetry.status_fields(), + "finished_at": _now(), + "elapsed_ms": int((perf_counter() - started) * 1000), + } + status = _write_status_unlocked(job_dir, status) + finally: + try: + attempt_result.unlink() + except FileNotFoundError: + pass + telemetry.record_duration("result_write", telemetry.phase_timings_ms.get("result_write", 0)) + telemetry.flush() + emit({"phase": "complete", "completed_repos": len(paths), "total_repos": len(paths)}, terminal=True) + return 0 + except RouterJobAlreadyActive as exc: + emit( + { + "phase": "failed", + "error_type": type(exc).__name__, + "message": _safe_error_message(exc), + "elapsed_ms": int((perf_counter() - started) * 1000), + }, + scope="attempt", terminal=True, + ) + return 1 + except RouterJobLostOwnership as exc: + emit( + { + "phase": "failed", + "error_type": type(exc).__name__, + "message": _safe_error_message(exc), + "elapsed_ms": int((perf_counter() - started) * 1000), + }, + scope="attempt", terminal=True, + ) + return 1 + except RouterJobCancelled as exc: + update( + status="cancelled", + phase="cancelled", + result_available=False, + error_type=type(exc).__name__, + message=_safe_error_message(exc), + finished_at=_now(), + ) + emit({"phase": "cancelled", "completed_repos": status.get("completed_repos", 0), "total_repos": status.get("total_repos")}, terminal=True) + return 2 + except BaseException as exc: + update( + status="failed", + phase="failed", + result_available=False, + error_type=type(exc).__name__, + message=_safe_error_message(exc), + finished_at=_now(), + ) + emit({"phase": "failed", "completed_repos": status.get("completed_repos", 0), "total_repos": status.get("total_repos")}, terminal=True) + return 1 + finally: + if stop_heartbeat is not None: + stop_heartbeat.set() + _release_lease(job_dir, run_token, lease_lock) + + +def main(argv: list[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + if len(argv) in {2, 3} and argv[0] == "_worker": + return run_router_job_worker(Path(argv[1]), argv[2] if len(argv) == 3 else None) + raise SystemExit("usage: python -m index_graph.router_jobs _worker JOB_DIR [RUN_TOKEN]") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/client-plugin/server/src/index_graph/scan.py b/client-plugin/server/src/index_graph/scan.py new file mode 100644 index 0000000..1c078a2 --- /dev/null +++ b/client-plugin/server/src/index_graph/scan.py @@ -0,0 +1,579 @@ +"""Discovery, parallel git fan-out, and map assembly.""" + +from __future__ import annotations + +import hashlib +import json +import os +import sys +from collections import Counter +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from time import perf_counter +from typing import Any, Callable + +from .classify import classify +from .config import Config +from .gitmeta import STATUS_ARGS, STATUS_GLOBAL_ARGS, GitMetadataError, repo_metadata, run_git_checked +from .model import SCHEMA_VERSION, Map, RepoRow + + +DEFAULT_INTERACTIVE_BUDGET_MS = 12000 +DEFAULT_INTERACTIVE_REPO_LIMIT = 300 +_MAP_RESUME_ROW_SCHEMA = "index.map-resume-row/v2" + + +class ScanBudget: + """Cooperative wall-clock budget for workspace traversal.""" + + def __init__(self, budget_ms: int | None): + self.budget_ms = int(budget_ms or 0) + self.started = perf_counter() + self.deadline = self.started + (self.budget_ms / 1000.0) if self.budget_ms > 0 else None + self.exhausted = False + self.last_path: Path | None = None + + def checkpoint(self, path: Path) -> bool: + self.last_path = Path(path) + if self.deadline is None: + return True + if perf_counter() <= self.deadline: + return True + self.exhausted = True + return False + + @property + def elapsed_ms(self) -> int: + return int((perf_counter() - self.started) * 1000) + + +class ScanBudgetExceeded(RuntimeError): + """Raised by caller wrappers when a partial discovery must not be authoritative.""" + + def __init__(self, *, root: Path, budget: ScanBudget, repo_count: int, skipped: list | None = None): + self.root = Path(root) + self.budget_ms = budget.budget_ms + self.elapsed_ms = budget.elapsed_ms + self.repo_count = repo_count + self.skipped = list(skipped or []) + self.last_path = str(budget.last_path) if budget.last_path is not None else None + super().__init__( + f"repository discovery budget exhausted after {self.elapsed_ms} ms " + f"(budget_ms={self.budget_ms}, partial_repos={repo_count})" + ) + + +class ScanWorkloadExceeded(RuntimeError): + """Raised when complete discovery is too large for an interactive graph build.""" + + def __init__(self, *, repo_count: int, repo_limit: int, budget_ms: int): + self.repo_count = repo_count + self.repo_limit = repo_limit + self.budget_ms = budget_ms + super().__init__( + f"workspace has {repo_count} repositories, exceeding the interactive graph limit " + f"of {repo_limit} while budget_ms={budget_ms}" + ) + + +def default_interactive_budget_ms() -> int: + raw = os.environ.get("INDEX_INTERACTIVE_BUDGET_MS") + if raw is None or raw == "": + return DEFAULT_INTERACTIVE_BUDGET_MS + try: + value = int(raw) + except ValueError: + return DEFAULT_INTERACTIVE_BUDGET_MS + return max(0, value) + + +def default_interactive_repo_limit() -> int: + raw = os.environ.get("INDEX_INTERACTIVE_REPO_LIMIT") + if raw is None or raw == "": + return DEFAULT_INTERACTIVE_REPO_LIMIT + try: + value = int(raw) + except ValueError: + return DEFAULT_INTERACTIVE_REPO_LIMIT + return max(0, value) + + +def enforce_interactive_repo_limit(repo_count: int, *, budget_ms: int | None) -> None: + if not budget_ms: + return + limit = default_interactive_repo_limit() + if limit and repo_count > limit: + raise ScanWorkloadExceeded(repo_count=repo_count, repo_limit=limit, budget_ms=int(budget_ms)) + + +def _root_hash(root: Path) -> str: + return hashlib.sha256(str(root.resolve()).encode("utf-8")).hexdigest()[:16] + + +def _sha256_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _warn(message: str) -> None: + try: + print(message, file=sys.stderr) + except UnicodeError: + safe = message.encode("utf-8", "backslashreplace").decode("ascii", "replace") + print(safe, file=sys.stderr) + + +def discover_repos(root: Path, config: Config, *, + skipped: list | None = None, + checkpoint: Callable[[Path], bool] | None = None) -> list[Path]: + root = Path(root) + prune = config.prune + repos: set[Path] = set() + def _onerror(exc: OSError) -> None: + # a directory os.walk could not read narrows the scan: record it so a + # partial scan is a receiptable fact, not a stderr-only warning that a + # certificate can MATCH straight past + if skipped is not None: + skipped.append(getattr(exc, "filename", None) or str(exc)) + _warn(f"warning: skipped unreadable directory during repo discovery: {exc}") + + for dirpath, dirnames, filenames in os.walk(root, onerror=_onerror): + current = Path(dirpath) + if checkpoint is not None and not checkpoint(current): + if skipped is not None: + skipped.append(f"scan-budget-exhausted:{current}") + _warn(f"warning: repository discovery budget exhausted at {current}") + dirnames[:] = [] + break + is_repo = ".git" in dirnames or ".git" in filenames + if is_repo: + repos.add(current) + if current != root and not config.descend_into_repos: + dirnames[:] = [] + continue + dirnames[:] = sorted((name for name in dirnames if name not in prune), key=str.lower) + return sorted(repos, key=lambda p: p.relative_to(root).as_posix().lower()) + + +def repo_key_map( + root: Path, + repos: list[Path], + *, + include_root_repo: bool = False, +) -> dict[str, Path]: + """Map discovered repos to stable keys without dropping duplicate basenames. + + Unique basenames keep the short legacy key. When multiple repos share a + basename, each duplicate gets its relative workspace path as the key. + """ + root = Path(root) + filtered = [ + repo for repo in repos + if include_root_repo or repo != root or len(repos) == 1 + ] + counts = Counter(repo.name for repo in filtered) + keyed: dict[str, Path] = {} + for repo in filtered: + if counts[repo.name] == 1: + key = repo.name + else: + key = repo.relative_to(root).as_posix() + keyed[key] = repo + return keyed + + +def _relative(path: Path, root: Path) -> str: + try: + return path.relative_to(root).as_posix() or "." + except ValueError: + return path.name + + +@dataclass(frozen=True) +class _ResumeEntry: + row: RepoRow + identity: dict[str, Any] + + +def _repo_row_from_meta(repo: Path, root: Path, config: Config, meta: dict[str, Any]) -> RepoRow: + rel = _relative(repo, root) + class_ = classify(rel, True, meta["origin"], config) + origin = "" if class_ in config.omit_origin_classes else meta["origin"] + path = rel if config.portable else str(repo) + markers = tuple(name for name in config.markers if (repo / name).exists()) + return RepoRow( + path=path, class_=class_, branch=meta["branch"], head=meta["head"], + origin=origin, dirty_count=meta["dirty_count"], + untracked_count=meta["untracked_count"], markers=markers, + metadata_status=str(meta.get("metadata_status") or "ok"), + metadata_error=meta.get("metadata_error"), + ) + + +def _repo_row(repo: Path, root: Path, config: Config) -> RepoRow: + return _repo_row_from_meta(repo, root, config, repo_metadata(repo)) + + +def _repo_row_from_json(data: dict[str, Any]) -> RepoRow | None: + try: + return RepoRow( + path=str(data["path"]), + class_=str(data["class"]), + branch=str(data["branch"]), + head=str(data["head"]), + origin=str(data.get("origin", "")), + dirty_count=int(data.get("dirty_count", 0)), + untracked_count=int(data.get("untracked_count", 0)), + markers=tuple(str(item) for item in data.get("markers", [])), + metadata_status=str(data.get("metadata_status") or "ok"), + metadata_error=( + str(data["metadata_error"]) + if data.get("metadata_error") is not None + else None + ), + ) + except (KeyError, TypeError, ValueError): + return None + + +def repo_status_signature(repo: Path) -> str | None: + result = run_git_checked(repo, STATUS_ARGS, global_args=STATUS_GLOBAL_ARGS) + if not result.ok: + return None + return _sha256_text(result.stdout) + + +def _config_resume_identity(config: Config) -> str: + payload = { + "rules": [(rule.pattern, rule.class_) for rule in config.rules], + "prune": sorted(config.prune), + "markers": list(config.markers), + "descend_into_repos": config.descend_into_repos, + "include_root_repo": config.include_root_repo, + "omit_origin_classes": sorted(config.omit_origin_classes), + "portable": config.portable, + } + return _sha256_text(json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)) + + +def _file_identity(path: Path) -> dict[str, Any]: + try: + stat = path.stat() + except FileNotFoundError: + return {"state": "missing"} + except OSError as exc: + return {"state": "unreadable", "error": type(exc).__name__} + if path.is_dir(): + return {"state": "directory"} + try: + digest = _sha256_bytes(path.read_bytes()) + except FileNotFoundError: + return {"state": "missing"} + except OSError as exc: + return { + "state": "unreadable", + "error": type(exc).__name__, + "mtime_ns": stat.st_mtime_ns, + "size": stat.st_size, + } + return { + "state": "file", + "size": stat.st_size, + "sha256": digest, + } + + +def _git_dir(repo: Path) -> Path | None: + marker = repo / ".git" + try: + if marker.is_dir(): + return marker.resolve() + text = marker.read_text(encoding="utf-8", errors="replace").strip() + except OSError: + return None + prefix = "gitdir:" + if not text.lower().startswith(prefix): + return None + raw = text[len(prefix):].strip() + if not raw: + return None + path = Path(raw) + if not path.is_absolute(): + path = marker.parent / path + try: + return path.resolve() + except OSError: + return None + + +def _common_git_dir(git_dir: Path) -> Path: + common = git_dir / "commondir" + try: + raw = common.read_text(encoding="utf-8", errors="replace").strip() + except OSError: + return git_dir + if not raw: + return git_dir + path = Path(raw) + if not path.is_absolute(): + path = git_dir / path + try: + return path.resolve() + except OSError: + return git_dir + + +def _git_control_identity(repo: Path) -> str: + marker = repo / ".git" + parts: list[tuple[str, dict[str, Any]]] = [(".git", _file_identity(marker))] + git_dir = _git_dir(repo) + if git_dir is None: + return _sha256_text(json.dumps(parts, sort_keys=True, separators=(",", ":"), default=str)) + common_dir = _common_git_dir(git_dir) + for base_name, base in (("gitdir", git_dir), ("commondir", common_dir)): + for name in ("HEAD", "config", "index", "packed-refs", "commondir"): + parts.append((f"{base_name}/{name}", _file_identity(base / name))) + try: + head_text = (git_dir / "HEAD").read_text(encoding="utf-8", errors="replace").strip() + except OSError: + head_text = "" + if head_text.startswith("ref:"): + ref = head_text.split(":", 1)[1].strip() + if ref: + for base_name, base in (("gitdir", git_dir), ("commondir", common_dir)): + parts.append((f"{base_name}/{ref}", _file_identity(base / ref))) + return _sha256_text(json.dumps(parts, sort_keys=True, separators=(",", ":"), default=str)) + + +def _repo_resume_identity( + repo: Path, + root: Path, + config: Config, + *, + status_signature: str | None = None, +) -> dict[str, Any] | None: + signature = status_signature or repo_status_signature(repo) + if signature is None: + return None + marker_state = [] + for name in config.markers: + path = repo / name + try: + exists = path.exists() + except OSError: + exists = False + marker_state.append([name, exists]) + return { + "repo_relative": _relative(repo, root), + "config": _config_resume_identity(config), + "markers": marker_state, + "git_status": signature, + "git_control": _git_control_identity(repo), + } + + +def _load_resume_rows(resume_state: Path | None, root: Path) -> dict[str, _ResumeEntry]: + if resume_state is None: + return {} + rows: dict[str, _ResumeEntry] = {} + root_id = _root_hash(root) + try: + lines = resume_state.read_text(encoding="utf-8").splitlines() + except OSError: + return rows + for line in lines: + if not line.strip(): + continue + try: + rec = json.loads(line) + except json.JSONDecodeError: + continue + if rec.get("schema") != _MAP_RESUME_ROW_SCHEMA: + continue + if rec.get("root_sha256_prefix") != root_id: + continue + rel = rec.get("repo_relative") + row = _repo_row_from_json(rec.get("row") or {}) + identity = rec.get("identity") + if isinstance(rel, str) and row is not None and isinstance(identity, dict): + rows[rel] = _ResumeEntry(row, identity) + return rows + + +def _append_resume_row( + resume_state: Path | None, + root: Path, + repo: Path, + row: RepoRow, + identity: dict[str, Any] | None, +) -> None: + if resume_state is None or identity is None: + return + rec = { + "schema": _MAP_RESUME_ROW_SCHEMA, + "root_sha256_prefix": _root_hash(root), + "repo_relative": _relative(repo, root), + "identity": identity, + "row": row.to_json(), + } + try: + resume_state.parent.mkdir(parents=True, exist_ok=True) + with resume_state.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(rec, sort_keys=True, separators=(",", ":")) + "\n") + except OSError as exc: + _warn(f"warning: could not write map resume state {resume_state}: {exc}") + + +def _unknown_repo_row(repo: Path, root: Path, config: Config, exc: Exception) -> RepoRow: + rel = _relative(repo, root) + _warn(f"warning: failed to scan {rel}: {exc}") + class_ = classify(rel, True, "", config) if isinstance(exc, GitMetadataError) else "unknown" + return RepoRow( + path=(rel if config.portable else str(repo)), class_=class_, + branch="unknown", head="unknown", origin="", dirty_count=0, + untracked_count=0, markers=(), metadata_status="unknown", + metadata_error=type(exc).__name__, + ) + + +def _safe_repo_row(repo: Path, root: Path, config: Config) -> RepoRow: + # Spec §9: one repo's failure must degrade to a row, never crash the scan. + try: + return _repo_row(repo, root, config) + except Exception as exc: + return _unknown_repo_row(repo, root, config, exc) + + +def _safe_resume_entry(repo: Path, root: Path, config: Config) -> tuple[RepoRow, dict[str, Any] | None]: + try: + meta = repo_metadata(repo) + row = _repo_row_from_meta(repo, root, config, meta) + identity = _repo_resume_identity( + repo, + root, + config, + status_signature=meta.get("status_signature"), + ) + if row.metadata_status != "ok": + identity = None + return row, identity + except Exception as exc: + return _unknown_repo_row(repo, root, config, exc), None + + +def _cached_resume_identity(repo: Path, root: Path, config: Config) -> dict[str, Any] | None: + try: + return _repo_resume_identity(repo, root, config) + except Exception: + return None + + +def _top_level(root: Path, config: Config) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + for path in sorted(root.iterdir(), key=lambda item: item.name.lower()): + if path.name == ".git": + continue + try: + stat = path.stat() + is_dir = path.is_dir() + except OSError as exc: + _warn(f"warning: skipped top-level entry {path.name}: {exc}") + continue + entries.append({ + "name": path.name, + "kind": "directory" if is_dir else "file", + "class": classify(path.name, False, "", config), + "bytes": None if is_dir else stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime).astimezone().isoformat(timespec="seconds"), + }) + return entries + + +def _map_rows(root: Path, config: Config, repos: list[Path], resume_state: Path | None) -> list[RepoRow]: + cached = _load_resume_rows(resume_state, root) + rows_by_rel: dict[str, RepoRow] = {} + pending: list[Path] = [] + if resume_state is None: + with ThreadPoolExecutor(max_workers=config.jobs) as pool: + futures = {pool.submit(_safe_repo_row, repo, root, config): repo for repo in repos} + for future in as_completed(futures): + repo = futures[future] + rows_by_rel[_relative(repo, root)] = future.result() + return [rows_by_rel[_relative(repo, root)] for repo in repos] + + cached_candidates: list[Path] = [] + for repo in repos: + if _relative(repo, root) in cached: + cached_candidates.append(repo) + else: + pending.append(repo) + if cached_candidates: + with ThreadPoolExecutor(max_workers=config.jobs) as pool: + futures = {pool.submit(_cached_resume_identity, repo, root, config): repo for repo in cached_candidates} + for future in as_completed(futures): + repo = futures[future] + rel = _relative(repo, root) + entry = cached[rel] + current_identity = future.result() + if current_identity is not None and entry.identity == current_identity: + rows_by_rel[rel] = entry.row + else: + pending.append(repo) + if pending: + with ThreadPoolExecutor(max_workers=config.jobs) as pool: + futures = {pool.submit(_safe_resume_entry, repo, root, config): repo for repo in pending} + for future in as_completed(futures): + repo = futures[future] + row, identity = future.result() + rel = _relative(repo, root) + rows_by_rel[rel] = row + _append_resume_row(resume_state, root, repo, row, identity) + return [rows_by_rel[_relative(repo, root)] for repo in repos] + + +def build_map( + root: Path, + config: Config, + tool_version: str, + *, + resume_state: Path | None = None, +) -> Map: + root = root.resolve() + resume_state = resume_state.resolve() if resume_state is not None else None + repo_paths = discover_repos(root, config) + rows = _map_rows(root, config, repo_paths, resume_state) + class_counts: dict[str, int] = {} + for row in rows: + class_counts[row.class_] = class_counts.get(row.class_, 0) + 1 + return Map( + schema_version=SCHEMA_VERSION, + tool_version=tool_version, + generated_at=datetime.now().astimezone().isoformat(timespec="seconds"), + root_sha256_prefix=_root_hash(root), + root=None if config.portable else str(root), + absolute_paths_included=not config.portable, + repo_count=len(rows), + dirty_count=sum(row.dirty_count for row in rows if row.metadata_status == "ok"), + class_counts=class_counts, + top_level=tuple(_top_level(root, config)), + repositories=tuple(rows), + annotations=dict(config.annotations), + ) + + +def write_map( + root: Path, + config: Config, + tool_version: str, + output: Path, + *, + resume_state: Path | None = None, +) -> Map: + data = build_map(root, config, tool_version, resume_state=resume_state) + output.write_text(json.dumps(data.to_json(), indent=2) + "\n", encoding="utf-8") + return data diff --git a/client-plugin/server/src/index_graph/symbols/__init__.py b/client-plugin/server/src/index_graph/symbols/__init__.py new file mode 100644 index 0000000..cfab7bd --- /dev/null +++ b/client-plugin/server/src/index_graph/symbols/__init__.py @@ -0,0 +1,24 @@ +"""Symbol-level intelligence: a re-checkable, deterministic call/reference graph. + +Extends the module-import graph down to functions, classes, and methods. Within +a module, calls resolve exactly via AST (high confidence). Across modules, an +imported name that binds to a real definition resolves best-effort (moderate); +anything the static scan cannot bind is surfaced honestly as unresolved, never +guessed. The graph seals with the same MATCH/DRIFT/UNVERIFIABLE certificate the +module graph uses. +""" +from __future__ import annotations + +from .model import (SymbolCall, SymbolCoverage, SymbolDefinition, SymbolGraph) +from .inheritance import InheritanceEdge, extract_inheritance_edges +from .navigate import (find_definitions, find_implementations, find_references) +from .build import (build_symbol_graph, build_symbol_navigator, + symbol_graph_to_claims, symbol_graph_to_payload) + +__all__ = [ + "SymbolCall", "SymbolCoverage", "SymbolDefinition", "SymbolGraph", + "InheritanceEdge", "extract_inheritance_edges", + "find_definitions", "find_implementations", "find_references", + "build_symbol_graph", "build_symbol_navigator", + "symbol_graph_to_claims", "symbol_graph_to_payload", +] diff --git a/client-plugin/server/src/index_graph/symbols/build.py b/client-plugin/server/src/index_graph/symbols/build.py new file mode 100644 index 0000000..e9bd5e3 --- /dev/null +++ b/client-plugin/server/src/index_graph/symbols/build.py @@ -0,0 +1,111 @@ +"""Assemble the SymbolGraph: definitions + resolved/unresolved calls + coverage. + +Deterministic by construction: definitions and calls are sorted, fan-in/out are +derived from the sorted resolved calls, and the whole graph serializes to a +canonical payload whose SHA is byte-identical across runs. +""" +from __future__ import annotations + +from pathlib import Path + +from ..internals.modules import discover_modules +from .calls import extract_symbol_calls +from .definitions import extract_symbol_definitions +from .imports import module_import_bindings +from .inheritance import InheritanceEdge, extract_inheritance_edges +from .model import SymbolCall, SymbolCoverage, SymbolDefinition, SymbolGraph + + +def _python_ids(repo_root: Path) -> set[str]: + return {m.id for m in discover_modules(repo_root) if m.language == "python"} + + +def _fan(calls: tuple[SymbolCall, ...]) -> tuple[dict[str, int], dict[str, int]]: + fan_out: dict[str, int] = {} + fan_in: dict[str, int] = {} + seen_out: set[tuple[str, str]] = set() + seen_in: set[tuple[str, str]] = set() + for c in calls: + if c.to_symbol is None: + continue + if (c.from_symbol, c.to_symbol) not in seen_out: + seen_out.add((c.from_symbol, c.to_symbol)) + fan_out[c.from_symbol] = fan_out.get(c.from_symbol, 0) + 1 + if (c.to_symbol, c.from_symbol) not in seen_in: + seen_in.add((c.to_symbol, c.from_symbol)) + fan_in[c.to_symbol] = fan_in.get(c.to_symbol, 0) + 1 + return fan_in, fan_out + + +def build_symbol_graph(repo_root: Path, repo_name: str | None = None) -> SymbolGraph: + graph, _ = build_symbol_navigator(repo_root, repo_name) + return graph + + +def build_symbol_navigator( + repo_root: Path, repo_name: str | None = None, +) -> tuple[SymbolGraph, list[InheritanceEdge]]: + """Build the symbol graph and its resolved inheritance edges in one pass. + + The extraction (definitions, imports, calls) is shared, so navigation + (definition/references/implementations) rides on the exact same evidence the + graph seals. Inheritance edges power find-implementations and are never + guessed: an external or unbindable base yields no edge. + """ + root = repo_root.resolve() + name = repo_name or root.name + ids = _python_ids(root) + definitions, parse_errors = extract_symbol_definitions(root, ids) + import_tables = module_import_bindings(root, ids) + calls_list, dynamic = extract_symbol_calls(root, definitions, ids, import_tables) + edges = extract_inheritance_edges(root, definitions, ids, import_tables) + calls = tuple(calls_list) + resolved = sum(1 for c in calls if c.to_symbol is not None) + coverage = SymbolCoverage( + symbols=len(definitions), + resolved_calls=resolved, + unresolved_calls=len(calls) - resolved, + parse_errors=tuple(parse_errors), + dynamic_calls=tuple(dynamic), + ) + fan_in, fan_out = _fan(calls) + graph = SymbolGraph(repo=name, symbols=tuple(definitions), calls=calls, + coverage=coverage, fan_in=fan_in, fan_out=fan_out) + return graph, edges + + +def _definition_payload(d: SymbolDefinition) -> dict: + return {"id": d.id, "name": d.name, "kind": d.kind, "module_id": d.module_id, + "file": d.file, "line": d.line, "parent": d.parent, "is_public": d.is_public} + + +def _call_payload(c: SymbolCall) -> dict: + return {"from_symbol": c.from_symbol, "to_symbol": c.to_symbol, "to_name": c.to_name, + "kind": c.kind, "file": c.evidence_file, "line": c.evidence_line, + "raw": c.raw, "resolution": c.resolution, "confidence": c.confidence} + + +def symbol_graph_to_payload(g: SymbolGraph) -> dict: + """A canonical, JSON-serializable projection for hashing, CLI, and MCP.""" + return { + "repo": g.repo, + "symbols": [_definition_payload(d) for d in g.symbols], + "calls": [_call_payload(c) for c in g.calls], + "fan_in": dict(sorted(g.fan_in.items())), + "fan_out": dict(sorted(g.fan_out.items())), + "coverage": { + "complete": g.coverage.complete, + "symbols": g.coverage.symbols, + "resolved_calls": g.coverage.resolved_calls, + "unresolved_calls": g.coverage.unresolved_calls, + "parse_errors": list(g.coverage.parse_errors), + "dynamic_calls": [{"file": f, "line": ln} for f, ln in g.coverage.dynamic_calls], + }, + } + + +def symbol_graph_to_claims(g: SymbolGraph) -> set[tuple[str, str]]: + """The set of resolved (caller, callee) edges the wiki may claim and the + verifier re-derives. Only edges with a real resolved target are claimable; + an unresolved reference is never a claimable edge.""" + return {(c.from_symbol, c.to_symbol) for c in g.calls if c.to_symbol is not None} diff --git a/client-plugin/server/src/index_graph/symbols/calls.py b/client-plugin/server/src/index_graph/symbols/calls.py new file mode 100644 index 0000000..ccbb554 --- /dev/null +++ b/client-plugin/server/src/index_graph/symbols/calls.py @@ -0,0 +1,249 @@ +"""Extract call sites and resolve them: exact within-module, best-effort cross-module. + +Resolution is layered and honest: + - ``foo()`` where ``foo`` is defined at module level in this file -> exact/high. + - ``self.m()`` inside a method of class C where C defines ``m`` -> exact/high. + - ``foo()`` where ``foo`` is a ``from import foo`` binding that + names a real definition in the target module -> cross_module/moderate. + - anything else (undefined name, attribute on a non-self object, a name that + imports but names no definition) -> cross_module_unresolved/low, never guessed. + - ``getattr(...)`` / a call whose func is neither a Name nor an Attribute + (a variable holding a function) -> flagged as a dynamic-dispatch coverage gap. +""" +from __future__ import annotations + +import ast +from collections.abc import Callable +from pathlib import Path + +from ..graph.walk import walk_files +from ..internals.modules import _strip_suffix +from .model import SymbolCall, SymbolDefinition + +_FUNC = (ast.FunctionDef, ast.AsyncFunctionDef) +_DEF = (*_FUNC, ast.ClassDef) + + +def _line_text(lines: list[str], lineno: int) -> str: + return lines[lineno - 1].strip() if 1 <= lineno <= len(lines) else "" + + +def _is_getattr(func: ast.AST) -> bool: + return isinstance(func, ast.Name) and func.id == "getattr" + + +def _scope_locals(func: ast.AST) -> frozenset: + """Names bound in a function's own scope: parameters plus every name stored + (assignment, for-target, with-alias, walrus, except-alias, nested def/class + name) within its body, NOT descending into nested function/lambda scopes + (those open their own scope). A call on any of these is a local value, so it + must not resolve to a module-level def of the same name.""" + names: set[str] = set() + args = getattr(func, "args", None) + if isinstance(args, ast.arguments): + for a in (*getattr(args, "posonlyargs", []), *args.args, *args.kwonlyargs): + names.add(a.arg) + if args.vararg: + names.add(args.vararg.arg) + if args.kwarg: + names.add(args.kwarg.arg) + + def walk(node: ast.AST) -> None: + for child in ast.iter_child_nodes(node): + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + # a nested def/lambda opens its own scope, but its NAME is a + # local binding in this scope + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)): + names.add(child.name) + continue + if isinstance(child, ast.ClassDef): + names.add(child.name) + continue + if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Store): + names.add(child.id) + walk(child) + + for stmt in getattr(func, "body", []): + walk(stmt) + return frozenset(names) + + +class _Walker: + """Walk one module's AST, emitting a SymbolCall per resolvable/unresolvable + call site and recording dynamic-dispatch sites as coverage gaps.""" + + def __init__(self, module_id: str, rel: str, lines: list[str], + local_defs: dict[str, str], class_methods: dict[str, dict[str, str]], + imports: dict[str, tuple[str, str]], + cross_target: Callable[[tuple[str, str]], str | None]): + self.module_id = module_id + self.rel = rel + self.lines = lines + self.local_defs = local_defs # bare name -> module-level symbol id + self.class_methods = class_methods # class id -> {method name -> method id} + self.imports = imports # bare name -> (target_module_id, name) + self.cross_target = cross_target # (module_id, name) -> resolved symbol id | None + self.calls: list[SymbolCall] = [] + self.dynamic: list[tuple[str, int]] = [] + + def visit_body(self, body, enclosing: str | None, parent_id: str | None, + class_id: str | None, scope: frozenset = frozenset()) -> None: + """`enclosing` is the caller symbol calls are attributed to; `parent_id` + is the lexical id used to build child symbol ids (matches definitions.py); + `class_id` is the nearest enclosing class, for `self.m()` resolution; + `scope` is the set of names bound in enclosing function scopes, so a call + on a locally-bound name that shadows a module def is not mis-resolved.""" + for node in body: + self._visit(node, enclosing, parent_id, class_id, scope) + + def _visit(self, node: ast.AST, enclosing: str | None, parent_id: str | None, + class_id: str | None, scope: frozenset = frozenset()) -> None: + if isinstance(node, ast.ClassDef): + base = parent_id or self.module_id + cid = f"{base}::{node.name}" + # class-level statements have no caller symbol; class_id becomes cid + self.visit_body(node.body, enclosing=None, parent_id=cid, class_id=cid, + scope=scope) + return + if isinstance(node, _FUNC): + base = parent_id or self.module_id + sym_id = f"{base}::{node.name}" + # a name bound in THIS function (parameter, assignment, for-target, + # with-alias, ...) shadows a module-level def of the same name, so a + # bare call on it is a local value, not the module symbol. Union with + # enclosing scopes so a closure over an outer local is honored too. + inner = scope | _scope_locals(node) + # class_id persists into the function body so `self.m()` resolves + # against the enclosing class (also for nested closures in a method). + self.visit_body(node.body, enclosing=sym_id, parent_id=sym_id, + class_id=class_id, scope=inner) + return + # a plain statement: collect its calls, but hand any nested def/class + # back to _visit so their bodies are attributed to the right symbol. + self._scan(node, enclosing, parent_id, class_id, scope) + + def _scan(self, node: ast.AST, enclosing: str | None, parent_id: str | None, + class_id: str | None, scope: frozenset = frozenset()) -> None: + """Recurse a statement's expression tree, emitting calls attributed to + `enclosing`, but re-dispatch nested defs/classes to `_visit` (they open a + new caller scope) so their inner calls are not mis-attributed.""" + for child in ast.iter_child_nodes(node): + if isinstance(child, _DEF): + self._visit(child, enclosing, parent_id, class_id, scope) + continue + if isinstance(child, ast.Call): + self._call(child, enclosing, class_id, scope) + self._scan(child, enclosing, parent_id, class_id, scope) + + def _call(self, node: ast.Call, enclosing: str | None, class_id: str | None, + scope: frozenset = frozenset()) -> None: + if enclosing is None: + return # module/class-level calls have no caller symbol to attribute + func = node.func + if _is_getattr(func): + self.dynamic.append((self.rel, node.lineno)) + return + if isinstance(func, ast.Name): + self._resolve_name(func.id, node.lineno, enclosing, scope) + elif isinstance(func, ast.Attribute): + self._resolve_attr(func, node.lineno, enclosing, class_id) + else: + # a call on a subscript/call result: a variable function, dynamic + self.dynamic.append((self.rel, node.lineno)) + + def _resolve_name(self, name: str, lineno: int, enclosing: str, + scope: frozenset = frozenset()) -> None: + raw = _line_text(self.lines, lineno) + if name in scope: + # a locally-bound name shadows any module def or import: the callee + # is a runtime value, statically unknown, never an exact edge + self._emit(enclosing, None, name, lineno, raw, + "local_binding_unresolved", "low") + return + local = self.local_defs.get(name) + if local is not None: + self._emit(enclosing, local, name, lineno, raw, "exact", "high") + return + binding = self.imports.get(name) + if binding is not None: + resolved = self.cross_target(binding) + if resolved is not None: + self._emit(enclosing, resolved, name, lineno, raw, "cross_module", "moderate") + return + self._emit(enclosing, None, name, lineno, raw, "cross_module_unresolved", "low") + + def _resolve_attr(self, func: ast.Attribute, lineno: int, enclosing: str, + class_id: str | None) -> None: + raw = _line_text(self.lines, lineno) + obj = func.value + # self.method() inside a method of a known class -> exact sibling lookup + if isinstance(obj, ast.Name) and obj.id == "self" and class_id is not None: + methods = self.class_methods.get(class_id, {}) + target = methods.get(func.attr) + if target is not None: + self._emit(enclosing, target, func.attr, lineno, raw, "exact", "high") + return + # any other attribute call: object type is not statically known + self._emit(enclosing, None, func.attr, lineno, raw, "cross_module_unresolved", "low") + + def _emit(self, frm: str, to: str | None, to_name: str, lineno: int, + raw: str, resolution: str, confidence: str) -> None: + self.calls.append(SymbolCall( + from_symbol=frm, to_symbol=to, to_name=to_name, kind="call", + evidence_file=self.rel, evidence_line=lineno, raw=raw, + resolution=resolution, confidence=confidence)) + + +def _module_maps(definitions: list[SymbolDefinition], module_id: str + ) -> tuple[dict[str, str], dict[str, dict[str, str]]]: + """(module-level name -> id, class id -> {method name -> id}) for one module.""" + local_defs: dict[str, str] = {} + class_methods: dict[str, dict[str, str]] = {} + # Hoisted out of the loop: computing this set per definition made the whole + # build quadratic in total symbol count on large repos. + class_ids = {d.id for d in definitions + if d.module_id == module_id and d.kind == "class"} + for d in definitions: + if d.module_id != module_id: + continue + if d.parent is None: + local_defs[d.name] = d.id + elif d.parent in class_ids: + class_methods.setdefault(d.parent, {})[d.name] = d.id + return local_defs, class_methods + + +def extract_symbol_calls( + repo_root: Path, definitions: list[SymbolDefinition], ids: set[str], + import_tables: dict[str, dict[str, tuple[str, str]]], +) -> tuple[list[SymbolCall], list[tuple[str, int]]]: + """Return (calls sorted deterministically, dynamic-dispatch sites).""" + by_def_id = {d.id for d in definitions} + + def cross_target(binding: tuple[str, str]) -> str | None: + target_mod, name = binding + cand = f"{target_mod}::{name}" + return cand if cand in by_def_id else None + + calls: list[SymbolCall] = [] + dynamic: list[tuple[str, int]] = [] + for py in walk_files(repo_root, suffixes=(".py",)): + rel = py.relative_to(repo_root).as_posix() + module_id = _strip_suffix(rel) + if module_id not in ids: + continue + try: + text = py.read_text(encoding="utf-8-sig") + tree = ast.parse(text) + except (OSError, SyntaxError, ValueError): + continue + local_defs, class_methods = _module_maps(definitions, module_id) + walker = _Walker(module_id, rel, text.splitlines(), local_defs, + class_methods, import_tables.get(module_id, {}), cross_target) + walker.visit_body(tree.body, enclosing=None, parent_id=None, class_id=None) + calls += walker.calls + dynamic += walker.dynamic + calls.sort(key=lambda c: (c.from_symbol, c.to_symbol or "", c.to_name, + c.evidence_file, c.evidence_line)) + dynamic = sorted(set(dynamic)) + return calls, dynamic diff --git a/client-plugin/server/src/index_graph/symbols/definitions.py b/client-plugin/server/src/index_graph/symbols/definitions.py new file mode 100644 index 0000000..0ab4ee9 --- /dev/null +++ b/client-plugin/server/src/index_graph/symbols/definitions.py @@ -0,0 +1,95 @@ +"""Extract function/class/method definitions from Python source via AST. + +Definitions are the ground truth a call site resolves against. Only Python is +AST-exact here; other languages carry no symbol-level extraction (their +module-level graph is unchanged). Every definition carries file:line so the +wiki and the verifier can point at the exact evidence. +""" +from __future__ import annotations + +import ast +from pathlib import Path + +from ..graph.walk import walk_files +from ..internals.modules import _strip_suffix +from .model import SymbolDefinition + +_FUNC = (ast.FunctionDef, ast.AsyncFunctionDef) + + +def _kind(node: ast.AST, in_class: bool) -> str: + if isinstance(node, ast.ClassDef): + return "class" + is_async = isinstance(node, ast.AsyncFunctionDef) + if in_class: + return "async_method" if is_async else "method" + return "async_function" if is_async else "function" + + +def _collect(node: ast.AST, module_id: str, rel: str, parent_id: str | None, + in_class: bool, out: list[SymbolDefinition]) -> None: + """Recurse the AST body, emitting a SymbolDefinition per def/class. + + Nested functions and classes are flattened under their lexical parent id so + a `Class::method` and a `func::inner` are both addressable and unique. + """ + body = getattr(node, "body", []) + for child in body: + if isinstance(child, (*_FUNC, ast.ClassDef)): + name = child.name + sym_id = f"{parent_id}::{name}" if parent_id else f"{module_id}::{name}" + out.append(SymbolDefinition( + id=sym_id, name=name, kind=_kind(child, in_class), + module_id=module_id, file=rel, line=child.lineno, + parent=parent_id, is_public=not name.startswith("_"))) + _collect(child, module_id, rel, sym_id, + in_class=isinstance(child, ast.ClassDef), out=out) + + +def extract_symbol_definitions( + repo_root: Path, ids: set[str] | None = None, +) -> tuple[list[SymbolDefinition], list[str]]: + """Return (definitions sorted by id, parse-error file list). + + `ids` restricts extraction to known Python module ids when supplied; when + None, every .py file is scanned. + """ + definitions: list[SymbolDefinition] = [] + parse_errors: list[str] = [] + for py in walk_files(repo_root, suffixes=(".py",)): + rel = py.relative_to(repo_root).as_posix() + module_id = _strip_suffix(rel) + if ids is not None and module_id not in ids: + continue + try: + tree = ast.parse(py.read_text(encoding="utf-8-sig")) + except OSError: + # an unreadable file (locked / permission-denied / removed + # mid-scan) is a coverage gap, not a file to drop silently: the + # map must not claim it saw everything while swallowing a file + parse_errors.append(rel) + continue + except (SyntaxError, ValueError): + parse_errors.append(rel) + continue + _collect(tree, module_id, rel, None, in_class=False, out=definitions) + definitions = _dedupe_last(definitions) + definitions.sort(key=lambda d: d.id) + parse_errors.sort() + return definitions, parse_errors + + +def _dedupe_last(definitions: list[SymbolDefinition]) -> list[SymbolDefinition]: + """Collapse same-id definitions to one, keeping the last (source order). + + A legal Python redefinition (two ``def foo`` at module scope, or a + decorator that rebinds a name) would otherwise emit two SymbolDefinitions + with an identical id, which silently collide in the wiki page map and make + resolution ambiguous. Python's runtime keeps the last binding, so the static + graph keeps the last definition site too. ``_collect`` appends in source + order, so the later dict write wins. + """ + by_id: dict[str, SymbolDefinition] = {} + for d in definitions: + by_id[d.id] = d + return list(by_id.values()) diff --git a/client-plugin/server/src/index_graph/symbols/imports.py b/client-plugin/server/src/index_graph/symbols/imports.py new file mode 100644 index 0000000..bc4cdb3 --- /dev/null +++ b/client-plugin/server/src/index_graph/symbols/imports.py @@ -0,0 +1,72 @@ +"""Per-module import binding table: which local name maps to which module symbol. + +Only `from import name` bindings are recorded, because only +those can be resolved to a definition inside this repo. Plain `import pkg.mod` +and external imports are not bindable to a symbol and are left out (a call +through them stays honestly unresolved). +""" +from __future__ import annotations + +import ast +from pathlib import Path + +from ..graph.walk import walk_files +from ..internals.modules import (_dotted_to_id, _id_for, _pkg_depth, + _resolve_relative, _strip_suffix) + + +def _relative_target(from_id: str, level: int, module: str | None, + ids: set[str], depth: int) -> str | None: + if module is None: + return None + return _resolve_relative(from_id, level, module, ids, depth) + + +def _bindings_for_module(from_id: str, tree: ast.AST, ids: set[str], + depth: int) -> dict[str, tuple[str, str]]: + """Map a locally-bound name -> (target_module_id, imported_name).""" + bindings: dict[str, tuple[str, str]] = {} + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom): + continue + if node.level and node.level > 0: + # relative: from .pkg import name / from . import name + if node.module is None: + pkg_parts = from_id.split("/")[:-1] + if node.level > depth: + continue + base = pkg_parts[:len(pkg_parts) - (node.level - 1)] + for a in node.names: + tid = _id_for("/".join([*base, a.name]), ids) + if tid: + bindings[a.asname or a.name] = (tid, a.name) + continue + target_mod = _relative_target(from_id, node.level, node.module, ids, depth) + elif node.module: + target_mod = _dotted_to_id(node.module, ids) + else: + target_mod = None + if not target_mod: + continue + for a in node.names: + if a.name == "*": + continue + bindings[a.asname or a.name] = (target_mod, a.name) + return bindings + + +def module_import_bindings(repo_root: Path, ids: set[str]) -> dict[str, dict[str, tuple[str, str]]]: + """For each Python module id, its {local_name: (target_module_id, name)} table.""" + tables: dict[str, dict[str, tuple[str, str]]] = {} + for py in walk_files(repo_root, suffixes=(".py",)): + rel = py.relative_to(repo_root).as_posix() + from_id = _strip_suffix(rel) + if from_id not in ids: + continue + try: + tree = ast.parse(py.read_text(encoding="utf-8-sig")) + except (OSError, SyntaxError, ValueError): + continue + depth = _pkg_depth(repo_root, from_id) + tables[from_id] = _bindings_for_module(from_id, tree, ids, depth) + return tables diff --git a/client-plugin/server/src/index_graph/symbols/inheritance.py b/client-plugin/server/src/index_graph/symbols/inheritance.py new file mode 100644 index 0000000..efadea5 --- /dev/null +++ b/client-plugin/server/src/index_graph/symbols/inheritance.py @@ -0,0 +1,183 @@ +"""Class inheritance and method-override edges, resolved AST-exact in-repo. + +Powers find-implementations at symbol granularity. Two edge kinds, both carrying +file:line evidence and an honest resolution label: + + - a ``subclass`` edge: class C in this repo lists base B, where B binds (via a + ``from import B`` or a same-module ``class B``) to a real class + definition in this repo. A base that names an external class, or a name the + static scan cannot bind, is never guessed into an edge. + - an ``override`` edge: subclass C defines a method whose name also exists on + a resolved ancestor class. The edge points from the override site to the + ancestor's method, so find-implementations of ``Base::method`` returns every + in-repo override with its exact definition line. + +Only Python is AST-exact here; other languages carry no inheritance extraction, +matching the definitions/calls layers. +""" +from __future__ import annotations + +import ast +from dataclasses import dataclass +from pathlib import Path + +from ..graph.walk import walk_files +from ..internals.modules import _strip_suffix +from .model import SymbolDefinition + + +@dataclass(frozen=True) +class InheritanceEdge: + """A resolved class -> base edge, or a subclass-method -> base-method override.""" + kind: str # "subclass" | "override" + child: str # subclass id (subclass edge) or override method id (override edge) + parent: str # resolved base-class id, or ancestor method id + name: str # bare base-class name / method name as written + file: str # repo-relative path of the child site + line: int # 1-indexed line of the child (subclass def / override method def) + resolution: str # "exact" (same module) | "cross_module" (import-bound) + + +def _base_name(base: ast.expr) -> str | None: + """The bare name of a base-class expression, or None for a non-name base. + + ``class C(Base)`` -> "Base"; ``class C(pkg.Base)`` -> "Base" (attribute tail); + a subscript/call base (e.g. ``Generic[T]``) is not a bindable class name. + """ + if isinstance(base, ast.Name): + return base.id + if isinstance(base, ast.Attribute): + return base.attr + return None + + +def _class_bases(tree: ast.AST) -> dict[int, list[str]]: + """Map each class-def line to the list of bare base names it declares.""" + bases: dict[int, list[str]] = {} + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + names = [n for b in node.bases if (n := _base_name(b)) is not None] + if names: + bases[node.lineno] = names + return bases + + +def _resolve_base( + base_name: str, + module_id: str, + class_by_name_in_module: dict[str, dict[str, str]], + imports: dict[str, tuple[str, str]], +) -> tuple[str, str] | None: + """Resolve a bare base name to (class_id, resolution), or None if unbindable. + + Same-module class wins first (exact); otherwise an import binding that names + a real class in the target module resolves cross_module. A base that binds to + no in-repo class definition returns None and is never guessed into an edge. + """ + same = class_by_name_in_module.get(module_id, {}).get(base_name) + if same is not None: + return same, "exact" + binding = imports.get(base_name) + if binding is not None: + target_mod, imported = binding + cand = class_by_name_in_module.get(target_mod, {}).get(imported) + if cand is not None: + return cand, "cross_module" + return None + + +def _method_index( + definitions: list[SymbolDefinition], +) -> tuple[dict[str, dict[str, str]], dict[str, dict[str, str]]]: + """(class name -> id per module, class id -> {method name -> method id}).""" + class_by_name: dict[str, dict[str, str]] = {} + methods_of: dict[str, dict[str, str]] = {} + for d in definitions: + if d.kind == "class": + class_by_name.setdefault(d.module_id, {})[d.name] = d.id + class_ids = {i for m in class_by_name.values() for i in m.values()} + for d in definitions: + if d.parent in class_ids and d.kind in ("method", "async_method"): + methods_of.setdefault(d.parent, {})[d.name] = d.id + return class_by_name, methods_of + + +def extract_inheritance_edges( + repo_root: Path, + definitions: list[SymbolDefinition], + ids: set[str], + import_tables: dict[str, dict[str, tuple[str, str]]], +) -> list[InheritanceEdge]: + """Return resolved subclass + override edges, sorted deterministically. + + Overrides are computed against the direct resolved parent only; a diamond or + multi-level chain still yields one override edge per resolved parent that + declares the same method name, which is the honest, re-checkable claim. + """ + class_by_name, methods_of = _method_index(definitions) + class_line_to_id = {(d.file, d.line): d.id + for d in definitions if d.kind == "class"} + site_of = {d.id: (d.file, d.line) for d in definitions} + edges: list[InheritanceEdge] = [] + for py in walk_files(repo_root, suffixes=(".py",)): + rel = py.relative_to(repo_root).as_posix() + module_id = _strip_suffix(rel) + if module_id not in ids: + continue + try: + tree = ast.parse(py.read_text(encoding="utf-8-sig")) + except (OSError, SyntaxError, ValueError): + continue + imports = import_tables.get(module_id, {}) + for line, base_names in _class_bases(tree).items(): + child_id = class_line_to_id.get((rel, line)) + if child_id is None: + continue + for base_name in base_names: + resolved = _resolve_base(base_name, module_id, class_by_name, imports) + if resolved is None: + continue # external or unbindable base: never guessed + parent_id, resolution = resolved + edges.append(InheritanceEdge( + kind="subclass", child=child_id, parent=parent_id, + name=base_name, file=rel, line=line, resolution=resolution)) + edges.extend(_override_edges( + child_id, parent_id, resolution, methods_of, site_of)) + edges.sort(key=lambda e: (e.kind, e.child, e.parent, e.name, e.file, e.line)) + return _dedupe(edges) + + +def _override_edges( + child_id: str, parent_id: str, resolution: str, + methods_of: dict[str, dict[str, str]], + site_of: dict[str, tuple[str, int]], +) -> list[InheritanceEdge]: + """Override edges for every child method whose name the parent also defines. + + The edge's file:line is the override method's own definition site, taken + from the definition table so every hop stays evidence-backed. + """ + child_methods = methods_of.get(child_id, {}) + parent_methods = methods_of.get(parent_id, {}) + out: list[InheritanceEdge] = [] + for mname, mid in sorted(child_methods.items()): + parent_mid = parent_methods.get(mname) + if parent_mid is None: + continue + file, line = site_of.get(mid, ("", 0)) + out.append(InheritanceEdge( + kind="override", child=mid, parent=parent_mid, name=mname, + file=file, line=line, resolution=resolution)) + return out + + +def _dedupe(edges: list[InheritanceEdge]) -> list[InheritanceEdge]: + seen: set[tuple] = set() + out: list[InheritanceEdge] = [] + for e in edges: + key = (e.kind, e.child, e.parent, e.name) + if key in seen: + continue + seen.add(key) + out.append(e) + return out diff --git a/client-plugin/server/src/index_graph/symbols/model.py b/client-plugin/server/src/index_graph/symbols/model.py new file mode 100644 index 0000000..f02967f --- /dev/null +++ b/client-plugin/server/src/index_graph/symbols/model.py @@ -0,0 +1,62 @@ +"""Frozen data model for the symbol-level call/reference graph. + +A SymbolDefinition is a function/class/method the AST saw defined. A SymbolCall +is a call site, carrying file:line evidence and an honest resolution label: a +resolved edge names a real definition; an unresolved reference names only the +bare name it could not statically bind, and is never guessed into an edge. +""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SymbolDefinition: + """A function, class, or method the AST recorded as defined.""" + id: str # "module_id::name" or "module_id::Class::method" + name: str # bare name (unqualified) + kind: str # "function" | "async_function" | "class" | "method" | "async_method" + module_id: str # parent module id (matches InternalGraph module ids) + file: str # repo-relative path + line: int # 1-indexed definition line + parent: str | None # "module_id::Class" for a method; None for a top-level symbol + is_public: bool # name does not start with "_" + + +@dataclass(frozen=True) +class SymbolCall: + """A call or reference site, with evidence and an honest resolution label.""" + from_symbol: str # caller symbol id + to_symbol: str | None # resolved target id, or None when unresolved + to_name: str # bare name as written at the call site + kind: str # "call" + evidence_file: str # repo-relative path + evidence_line: int # 1-indexed line of the call site + raw: str # source-line fragment + resolution: str # "exact" | "cross_module" | "cross_module_unresolved" + confidence: str # "high" | "moderate" | "low" + + +@dataclass(frozen=True) +class SymbolCoverage: + """What the symbol scan could and could not statically see.""" + symbols: int + resolved_calls: int + unresolved_calls: int + parse_errors: tuple[str, ...] + dynamic_calls: tuple[tuple[str, int], ...] # (file, line) of getattr/variable dispatch + + @property + def complete(self) -> bool: + return not self.parse_errors and not self.dynamic_calls + + +@dataclass(frozen=True) +class SymbolGraph: + """Per-repository symbol graph, analogous to InternalGraph.""" + repo: str + symbols: tuple[SymbolDefinition, ...] + calls: tuple[SymbolCall, ...] + coverage: SymbolCoverage + fan_in: dict[str, int] # resolved-target id -> number of distinct callers + fan_out: dict[str, int] # caller id -> number of distinct resolved targets diff --git a/client-plugin/server/src/index_graph/symbols/navigate.py b/client-plugin/server/src/index_graph/symbols/navigate.py new file mode 100644 index 0000000..c8ce008 --- /dev/null +++ b/client-plugin/server/src/index_graph/symbols/navigate.py @@ -0,0 +1,89 @@ +"""Symbol-graph navigation: go-to-definition, find-references, find-implementations. + +A pure query layer over the wave-1 ``SymbolGraph`` plus resolved inheritance +edges. Every result carries file:line evidence, and every verdict defers to the +graph's own resolution so nothing is guessed: + + - definitions: symbols whose id or bare name matches the query. + - references: resolved callers of the matched symbols, plus a separate, + honestly-labeled list of unresolved same-name references (never callers). + - implementations: for a class query, its resolved subclasses; for a method + query, the in-repo overrides of that method. A bare method name resolves + against every matching definition so ``Base.method`` and ``method`` both work. + +The query is a symbol id (``module::name`` or ``Class::method``) or a bare name. +""" +from __future__ import annotations + +from .inheritance import InheritanceEdge +from .model import SymbolDefinition, SymbolGraph + + +def _matches(sym: SymbolDefinition, query: str) -> bool: + """A symbol matches a query by exact id, bare name, or a ``Class::method`` tail.""" + if sym.id == query or sym.name == query: + return True + # A `Class::method` or `module::name` tail lets a partial id resolve. + return sym.id.endswith("::" + query) + + +def find_definitions(graph: SymbolGraph, query: str) -> list[dict]: + """Go-to-definition: every matching symbol with its file:line site.""" + return [ + {"id": s.id, "name": s.name, "kind": s.kind, "module_id": s.module_id, + "file": s.file, "line": s.line, "is_public": s.is_public} + for s in graph.symbols if _matches(s, query) + ] + + +def find_references(graph: SymbolGraph, query: str) -> dict: + """Find-references: resolved callers (evidence-backed) + unresolved refs. + + A resolved caller names a real call edge whose target is a matched symbol. + An unresolved reference shares the bare name but bound to no edge; it is + reported separately and never counted as a caller. + """ + targets = {s.id for s in graph.symbols if _matches(s, query)} + names = {s.name for s in graph.symbols if _matches(s, query)} + references = [ + {"from_symbol": c.from_symbol, "to_symbol": c.to_symbol, + "to_name": c.to_name, "file": c.evidence_file, "line": c.evidence_line, + "raw": c.raw, "resolution": c.resolution, "confidence": c.confidence} + for c in graph.calls if c.to_symbol in targets + ] + references.sort(key=lambda r: (r["file"], r["line"], r["from_symbol"])) + unresolved = [ + {"from_symbol": c.from_symbol, "to_name": c.to_name, + "file": c.evidence_file, "line": c.evidence_line, "raw": c.raw} + for c in graph.calls if c.to_symbol is None and c.to_name in names + ] + unresolved.sort(key=lambda r: (r["file"], r["line"], r["from_symbol"])) + return {"references": references, "unresolved": unresolved} + + +def find_implementations( + graph: SymbolGraph, edges: list[InheritanceEdge], query: str, +) -> dict: + """Find-implementations: subclasses of a class, or overrides of a method. + + Resolution is by the matched definition's kind. A class query returns the + ``subclass`` edges whose parent is a matched class. A method query returns + the ``override`` edges whose parent is a matched method. When a query + matches both (rare bare-name collisions), both lists are populated honestly. + """ + class_targets = {s.id for s in graph.symbols + if _matches(s, query) and s.kind == "class"} + method_targets = {s.id for s in graph.symbols + if _matches(s, query) and s.kind in ("method", "async_method")} + subclasses = [_edge_row(e) for e in edges + if e.kind == "subclass" and e.parent in class_targets] + overrides = [_edge_row(e) for e in edges + if e.kind == "override" and e.parent in method_targets] + subclasses.sort(key=lambda r: (r["file"], r["line"], r["child"])) + overrides.sort(key=lambda r: (r["file"], r["line"], r["child"])) + return {"subclasses": subclasses, "overrides": overrides} + + +def _edge_row(e: InheritanceEdge) -> dict: + return {"child": e.child, "parent": e.parent, "name": e.name, + "file": e.file, "line": e.line, "resolution": e.resolution} diff --git a/client-plugin/server/src/index_graph/verify.py b/client-plugin/server/src/index_graph/verify.py new file mode 100644 index 0000000..af95885 --- /dev/null +++ b/client-plugin/server/src/index_graph/verify.py @@ -0,0 +1,87 @@ +"""Ground a structural claim against the verified graph: MATCH / REFUTED / UNVERIFIABLE. + +The deterministic anti-hallucination oracle. An agent asserts a dependency or an +existence claim about the code; index confirms or refutes it from the real graph with +file:line evidence, instead of the agent trusting its own memory. This artifact has its +own honest triad (a false claim is REFUTED, not DRIFT) and is re-checkable: a consumer +re-runs `recheck` and recomputes the hash rather than trusting the verdict. The content +hash covers the whole graph (graph-scoped provenance, matching the certificate +convention), so it binds the verdict to the exact graph state it was computed against. +""" +from __future__ import annotations + +from .certify import canonical_sha + + +def _evidence(rel: dict) -> str | None: + sigs = rel.get("signals") or [] + if not sigs: + return None + s = sigs[0] + f = s.get("file") + if not f: + return None + line = s.get("line") + return f"{f}:{line}" if line is not None else f + + +def _names(pack: dict) -> set[str]: + names = set(pack.get("roles", {}).keys()) + for r in pack.get("relations", []): + if r.get("from"): + names.add(r["from"]) + if r.get("to"): + names.add(r["to"]) + return names + + +def verify_claim(pack: dict, claim: dict) -> dict: + """Return {verdict, evidence, detail} for a claim against the pack.""" + kind = claim.get("kind") + names = _names(pack) + + if kind == "exists": + name = claim.get("name") + if name in names: + return {"verdict": "MATCH", "evidence": None, + "detail": f"{name} is a repo in the workspace"} + return {"verdict": "REFUTED", "evidence": None, + "detail": f"{name} is not a repo in the workspace"} + + if kind == "depends": + frm, to = claim.get("from"), claim.get("to") + if frm not in names or to not in names: + missing = frm if frm not in names else to + return {"verdict": "UNVERIFIABLE", "evidence": None, + "detail": f"{missing} is not a repo in the workspace"} + matches = [r for r in pack.get("relations", []) + if not r.get("external") and r.get("from") == frm and r.get("to") == to] + if matches: + # return the strongest edge's evidence (high > moderate > low), file as a + # deterministic tiebreak, so the oracle hands back its best witness, not the + # alphabetically-first one. + rank = {"high": 0, "moderate": 1, "low": 2} + best = min(matches, key=lambda r: (rank.get(r.get("confidence"), 3), + (r.get("signals") or [{}])[0].get("file", ""))) + detail = f"{frm} depends on {to}" + if len(matches) > 1: + detail += f" ({len(matches)} edges agree)" + return {"verdict": "MATCH", "evidence": _evidence(best), "detail": detail} + return {"verdict": "REFUTED", "evidence": None, + "detail": f"no dependency {frm} -> {to} in the graph"} + + return {"verdict": "UNVERIFIABLE", "evidence": None, "detail": "unknown claim kind"} + + +def build_verification(pack: dict, claim: dict, *, tool_version: str, recheck: str) -> dict: + result = verify_claim(pack, claim) + return { + "schema": "index.verification/1", + "tool_version": tool_version, + "claim": claim, + "verdict": result["verdict"], + "evidence": result["evidence"], + "detail": result["detail"], + "content_sha256": canonical_sha(pack), + "recheck": recheck, + } diff --git a/client-plugin/server/src/index_graph/viz/__init__.py b/client-plugin/server/src/index_graph/viz/__init__.py new file mode 100644 index 0000000..113fc30 --- /dev/null +++ b/client-plugin/server/src/index_graph/viz/__init__.py @@ -0,0 +1,16 @@ +"""Zero-dependency renderers for the dependency-graph context pack.""" +from .layout import build_layout +from .svg import render_svg +from .mermaid import render_mermaid +from .charts import render_charts +from .html import render_html +from .manifest import render_manifest +from .atlas_layout import build_atlas_layout +from .atlas_svg import render_atlas_svg +from .atlas_html import render_atlas_html + +__all__ = [ + "build_layout", "render_svg", "render_mermaid", + "render_charts", "render_html", "render_manifest", + "build_atlas_layout", "render_atlas_svg", "render_atlas_html", +] diff --git a/client-plugin/server/src/index_graph/viz/atlas_assets.py b/client-plugin/server/src/index_graph/viz/atlas_assets.py new file mode 100644 index 0000000..626f10d --- /dev/null +++ b/client-plugin/server/src/index_graph/viz/atlas_assets.py @@ -0,0 +1,127 @@ +"""Atlas dashboard CSS + JS (string constants embedded into the self-contained HTML).""" +from __future__ import annotations + +ATLAS_CSS = """ +:root{--bg:#f4f3ef;--panel:rgba(255,255,255,.55);--ink:#0b0c0e;--soft:#2f3238;--muted:#585c64;--hairline:rgba(11,12,14,.14);--accent:#4636e8;--gold:#0b0c0e;--font-body:Arial,Helvetica,sans-serif;--font-mono:ui-monospace,SFMono-Regular,Consolas,monospace} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--font-body)} +main{display:grid;grid-template-columns:1fr 360px;min-height:100vh} +.skip-link{position:absolute;left:1rem;top:1rem;transform:translateY(-140%);background:var(--bg);border:1px solid var(--hairline);padding:.55rem .85rem;z-index:10} +.skip-link:focus{transform:none;outline:2px solid var(--accent);outline-offset:3px} +#stage{overflow:hidden;padding:1rem;position:relative}#stage svg{max-width:100%;height:auto;cursor:grab;background:var(--bg)} +#stage.grabbing svg{cursor:grabbing} +aside{border-left:1px solid var(--hairline);padding:1rem;font-family:var(--font-mono);font-size:.82rem;overflow:auto;background:var(--panel)} +.controls{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:.6rem;align-items:center} +.chip{cursor:pointer;border:1px solid var(--hairline);border-radius:6px;padding:.2em .5em;background:transparent;color:var(--ink)} +.chip[aria-pressed=true]{background:var(--accent);color:var(--bg)} +input[type=search]{flex:1;min-width:8rem;padding:.4em;background:transparent;color:var(--ink);border:1px solid var(--hairline);border-radius:6px} +#trail{font-family:var(--font-mono);font-size:.72rem;opacity:.8;margin:.2rem 0 .6rem;min-height:1.2em} +#trail a{color:var(--gold);cursor:pointer;text-decoration:underline} +#detail h3{margin:.2rem 0;color:var(--gold)}#detail h4{margin:.6rem 0 .2rem;color:var(--gold)} +#detail .md{font-family:var(--font-body);font-size:.95rem;line-height:1.5;border-top:1px solid var(--hairline);margin-top:.6rem;padding-top:.6rem} +#detail .md pre{background:rgba(255,255,255,.55);border:1px solid var(--hairline);padding:.5em;overflow:auto}#detail .md table{border-collapse:collapse} +#detail .md th,#detail .md td{border:1px solid var(--hairline);padding:.2em .5em} +#detail .md .wikilink{color:var(--accent);cursor:pointer}#detail .md .md-img{opacity:.6;font-style:italic} +a.wikilink{color:var(--accent)} +@media(max-width:820px){main{grid-template-columns:1fr}aside{border-left:none}} +""" + +ATLAS_SKIP_LINK = '' + +ATLAS_JS = r""" +const $=s=>document.querySelector(s),$$=s=>[...document.querySelectorAll(s)]; +const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const norm=s=>String(s).trim().toLowerCase().replace(/_/g,'-').replace(/ /g,'-'); +const repos={};(DATA.repos||[]).forEach(r=>repos[r.name]=r); +const docs={};(DATA.docs||[]).forEach(d=>docs[d.id]=d); +const tgt={}; // normalized name -> {kind,id} +(DATA.repos||[]).forEach(r=>{if(!(norm(r.name)in tgt))tgt[norm(r.name)]={kind:'repo',id:r.name};}); +(DATA.docs||[]).forEach(d=>{[d.title,d.id.split('/').pop().replace(/\.[^.]+$/,'')].forEach(c=>{if(!(norm(c)in tgt))tgt[norm(c)]={kind:'doc',id:d.id};});}); +function kedgesFrom(id){return (DATA.knowledge_edges||[]).filter(e=>e.from===id);} +function selectClear(){$$('.node,.docnode').forEach(n=>n.classList.remove('sel'));} +function detailRepo(name){selectClear(); + const g=$(`.node[data-name="${cssEsc(name)}"]`);if(g)g.classList.add('sel'); + const outs=(DATA.relations||[]).filter(e=>e.from===name&&!e.external); + const descBy=(DATA.knowledge_edges||[]).filter(e=>e.type==='describes'&&e.to===name); + $('#detail').innerHTML=`

      ${esc(name)} repo

      `+ + `
      roles: ${esc((DATA.roles[name]||[]).join(', '))||'none'}
      `+ + `

      depends on

      `+(outs.map(e=>`
      ${esc(e.to)} [${esc(e.confidence)}]
      `).join('')||'none')+ + `

      documented by

      `+(descBy.map(e=>linkNode(e.from,'doc')).join('')||'none'); + pushTrail({kind:'repo',id:name});} +function detailDoc(id){selectClear(); + const g=$(`.docnode[data-doc="${cssEsc(id)}"]`);if(g)g.classList.add('sel'); + const d=docs[id]||{title:id}; + const out=kedgesFrom(id); + const desc=out.filter(e=>e.type==='describes').map(e=>esc(e.to)).join(', '); + const links=out.filter(e=>e.type!=='describes').map(e=>linkNode(e.to,e.to_kind)).join('')||'none'; + const back=(DATA.backlinks&&DATA.backlinks[id]||[]).map(b=>linkNode(b.from,'doc')).join('')||'none'; + $('#detail').innerHTML=`

      ${esc(d.title)} doc

      `+ + (desc?`
      describes ${desc}
      `:'')+ + `

      links

      ${links}

      linked from

      ${back}`+ + `
      ${DATA.doc_html[id]||''}
      `; + wireWikilinks();pushTrail({kind:'doc',id});} +function linkNode(id,kind){const label=kind==='repo'?id:(docs[id]?docs[id].title:id); + return ``;} +function cssEsc(s){return String(s).replace(/["\\]/g,'\\$&');} +function go(kind,id){kind==='repo'?detailRepo(id):detailDoc(id); + const sel=kind==='repo'?`.node[data-name="${cssEsc(id)}"]`:`.docnode[data-doc="${cssEsc(id)}"]`; + const el=$(sel);if(el&&el.scrollIntoView)el.scrollIntoView({block:'center',inline:'center'});} +function wireWikilinks(){$$('#detail .wikilink,#detail .navlink').forEach(a=>a.addEventListener('click',ev=>{ + ev.preventDefault();const t=a.dataset.atlasTarget?tgt[a.dataset.atlasTarget]:{kind:a.dataset.kind,id:a.dataset.id}; + if(t)go(t.kind,t.id);}));} +let trail=[]; +function pushTrail(node){if(trail.length&&trail[trail.length-1].id===node.id)return;trail.push(node);renderTrail();} +function renderTrail(){$('#trail').innerHTML=trail.map((n,i)=>`${esc(n.id)}`).join(' > '); + $$('#trail a').forEach(a=>a.addEventListener('click',()=>{const n=trail[+a.dataset.i];trail=trail.slice(0,+a.dataset.i);go(n.kind,n.id);}));} +let view={k:1,tx:0,ty:0}; +function applyView(){const vp=$('#viewport');if(vp)vp.setAttribute('transform',`translate(${view.tx},${view.ty}) scale(${view.k})`);} +function svgPt(svg,cx,cy){const r=svg.getBoundingClientRect();const vb=svg.viewBox.baseVal; + return {x:(cx-r.left)/r.width*vb.width,y:(cy-r.top)/r.height*vb.height};} +function wireZoom(){const stage=$('#stage'),svg=stage&&stage.querySelector('svg');if(!svg)return; + svg.addEventListener('wheel',ev=>{ev.preventDefault();const p=svgPt(svg,ev.clientX,ev.clientY); + const f=ev.deltaY<0?1.1:1/1.1,nk=Math.min(8,Math.max(.2,view.k*f)); + view.tx=p.x-(p.x-view.tx)*(nk/view.k);view.ty=p.y-(p.y-view.ty)*(nk/view.k);view.k=nk;applyView();},{passive:false}); + let drag=null; + svg.addEventListener('pointerdown',ev=>{drag={x:ev.clientX,y:ev.clientY,tx:view.tx,ty:view.ty}; + stage.classList.add('grabbing');svg.setPointerCapture(ev.pointerId);}); + svg.addEventListener('pointermove',ev=>{if(!drag)return;const r=svg.getBoundingClientRect(),vb=svg.viewBox.baseVal; + view.tx=drag.tx+(ev.clientX-drag.x)*vb.width/r.width;view.ty=drag.ty+(ev.clientY-drag.y)*vb.height/r.height;applyView();}); + svg.addEventListener('pointerup',()=>{drag=null;stage.classList.remove('grabbing');}); + $('#zoom-reset').addEventListener('click',()=>{view={k:1,tx:0,ty:0};applyView();});} +function searchApply(){const q=$('#search').value.trim().toLowerCase();const on=new Set(); + $$('.node').forEach(g=>{const m=!q||g.dataset.name.toLowerCase().includes(q); + g.classList.toggle('dim',!m);if(m)on.add('repo:'+g.dataset.name);}); + $$('.docnode').forEach(g=>{const d=docs[g.dataset.doc]; + const m=!q||g.dataset.doc.toLowerCase().includes(q)||(d&&d.title.toLowerCase().includes(q)); + g.classList.toggle('dim',!m);if(m)on.add('doc:'+g.dataset.doc);}); + $$('.edge').forEach(p=>p.classList.toggle('dim',!!q&&!(on.has('repo:'+p.dataset.from)&&on.has('repo:'+p.dataset.to)))); + $$('.kedge').forEach(l=>{const t=on.has('repo:'+l.dataset.to)||on.has('doc:'+l.dataset.to); + l.classList.toggle('dim',!!q&&!(on.has('doc:'+l.dataset.from)&&t));});} +function wireMentions(){const b=$('#toggle-mentions');b.addEventListener('click',()=>{ + const on=b.getAttribute('aria-pressed')==='true';b.setAttribute('aria-pressed',String(!on)); + $$('.kedge-mentions').forEach(l=>{l.style.display=on?'none':'';});});} +function neighborhood(kind,id){const keep=new Set([kind+':'+id]); + (DATA.relations||[]).forEach(e=>{if(e.external)return; + if(kind==='repo'&&e.from===id)keep.add('repo:'+e.to); + if(kind==='repo'&&e.to===id)keep.add('repo:'+e.from);}); + (DATA.knowledge_edges||[]).forEach(e=>{ + if(kind==='doc'&&e.from===id)keep.add(e.to_kind+':'+e.to); + if(e.to===id&&((kind==='repo'&&e.to_kind==='repo')||(kind==='doc'&&e.to_kind==='doc')))keep.add('doc:'+e.from);}); + return keep;} +function focusOn(kind,id){const keep=neighborhood(kind,id); + $$('.node').forEach(g=>g.classList.toggle('dim',!keep.has('repo:'+g.dataset.name))); + $$('.docnode').forEach(g=>g.classList.toggle('dim',!keep.has('doc:'+g.dataset.doc))); + $$('.edge').forEach(p=>p.classList.toggle('dim',!(keep.has('repo:'+p.dataset.from)&&keep.has('repo:'+p.dataset.to)))); + $$('.kedge').forEach(l=>l.classList.toggle('dim',!(keep.has('doc:'+l.dataset.from)&&(keep.has('repo:'+l.dataset.to)||keep.has('doc:'+l.dataset.to)))));} +function clearFocus(){$$('.dim').forEach(e=>e.classList.remove('dim'));} +function wire(){ + $$('.node').forEach(g=>g.addEventListener('click',()=>detailRepo(g.dataset.name))); + $$('.docnode').forEach(g=>g.addEventListener('click',()=>detailDoc(g.dataset.doc))); + $$('.node').forEach(g=>g.addEventListener('dblclick',()=>focusOn('repo',g.dataset.name))); + $$('.docnode').forEach(g=>g.addEventListener('dblclick',()=>focusOn('doc',g.dataset.doc))); + $('#focus-clear').addEventListener('click',clearFocus); + $('#search').addEventListener('input',searchApply); + wireMentions(); + wireZoom(); +} +document.addEventListener('DOMContentLoaded',wire); +""" diff --git a/client-plugin/server/src/index_graph/viz/atlas_html.py b/client-plugin/server/src/index_graph/viz/atlas_html.py new file mode 100644 index 0000000..88b7044 --- /dev/null +++ b/client-plugin/server/src/index_graph/viz/atlas_html.py @@ -0,0 +1,43 @@ +"""Assemble the self-contained atlas dashboard document.""" +from __future__ import annotations + +import json + +from ..knowledge.markdown import render_markdown +from .theme import css_variables +from .atlas_assets import ATLAS_CSS, ATLAS_JS, ATLAS_SKIP_LINK + + +def _backlinks(pack: dict) -> dict: + out: dict[str, list] = {} + for e in sorted(pack.get("knowledge_edges", []), key=lambda e: (e["to"], e["from"], e["type"])): + out.setdefault(e["to"], []).append({"from": e["from"], "type": e["type"]}) + return out + + +def render_atlas_html(pack: dict, docs: list, *, svg: str, include_external: bool = True) -> str: + rendered = {d.rel_path: render_markdown(d.body) for d in sorted(docs, key=lambda d: d.rel_path)} + data = dict(pack) + data["doc_html"] = rendered + data["backlinks"] = _backlinks(pack) + blob = json.dumps(data, sort_keys=True, separators=(",", ":")).replace("<", "\\u003c") + return ( + "" + '' + '' + "index | atlas" + f"" + f"{ATLAS_SKIP_LINK}" + '
      ' + '
      ' + '' + '' + '' + '' + "
      " + '
      ' + f"{svg}
      " + '
      ' + f"" + "" + ) diff --git a/client-plugin/server/src/index_graph/viz/atlas_layout.py b/client-plugin/server/src/index_graph/viz/atlas_layout.py new file mode 100644 index 0000000..40f608f --- /dev/null +++ b/client-plugin/server/src/index_graph/viz/atlas_layout.py @@ -0,0 +1,103 @@ +"""Two-layer atlas layout: repo positions (reused) + doc satellites + knowledge band.""" +from __future__ import annotations + +from dataclasses import dataclass + +from .layout import build_layout, LayoutModel, MARGIN + +_DOC_W, _DOC_H, _ROW_GAP, _COL_GAP = 160.0, 30.0, 10.0, 24.0 + + +@dataclass(frozen=True) +class DocNode: + id: str + title: str + x: float + y: float + w: float + h: float + describes: str | None # repo name, or None for a band (cross-cutting) doc + + +@dataclass(frozen=True) +class KEdge: + type: str # describes | links-to | mentions + frm: str + to: str + to_kind: str # repo | doc + points: tuple[tuple[float, float], tuple[float, float]] + + +@dataclass(frozen=True) +class AtlasLayout: + repo_layout: LayoutModel + docs: tuple[DocNode, ...] + kedges: tuple[KEdge, ...] + width: float + height: float + + +def build_atlas_layout(pack: dict, *, include_external: bool = True) -> AtlasLayout: + repo_layout = build_layout(pack, include_external=include_external) + repo_by_name = {n.name: n for n in repo_layout.nodes} + + # describes target per doc (first by sorted edge order wins -> deterministic) + describes: dict[str, str] = {} + for e in sorted(pack.get("knowledge_edges", []), key=lambda e: (e["from"], e["type"], e["to_kind"], e["to"])): + if e["type"] == "describes" and e["from"] not in describes: + describes[e["from"]] = e["to"] + + docs_meta = sorted(pack.get("docs", []), key=lambda d: d["id"]) + region_top = repo_layout.height + 50.0 + placed: dict[str, DocNode] = {} + + # described docs -> one column per repo, columns flow left-to-right without overlapping + by_repo: dict[str, list[dict]] = {} + for d in docs_meta: + target = describes.get(d["id"]) + if target in repo_by_name: + by_repo.setdefault(target, []).append(d) + cursor_x = MARGIN + col_depth = 0 + for repo in sorted(by_repo, key=lambda r: (repo_by_name[r].x, r)): + x = max(repo_by_name[repo].x, cursor_x) # under its repo, never overlapping the prior column + for k, d in enumerate(by_repo[repo]): + y = region_top + k * (_DOC_H + _ROW_GAP) + placed[d["id"]] = DocNode(d["id"], d["title"], x, y, _DOC_W, _DOC_H, repo) + cursor_x = x + _DOC_W + _COL_GAP + col_depth = max(col_depth, len(by_repo[repo])) + + # band docs (describe nothing / unknown repo) -> a wrapping row beneath the columns + band_top = region_top + max(col_depth, 1) * (_DOC_H + _ROW_GAP) + 40.0 + width_guess = max(repo_layout.width, cursor_x) + bx = MARGIN + for d in docs_meta: + if d["id"] in placed: + continue + if bx > MARGIN and bx + _DOC_W + MARGIN > width_guess: + bx = MARGIN + band_top += _DOC_H + _ROW_GAP + placed[d["id"]] = DocNode(d["id"], d["title"], bx, band_top, _DOC_W, _DOC_H, None) + bx += _DOC_W + _COL_GAP + + docs = tuple(placed[d["id"]] for d in docs_meta) + + def _center(node_id: str, kind: str): + if kind == "repo" and node_id in repo_by_name: + r = repo_by_name[node_id] + return (r.x + r.w / 2.0, r.y + r.h / 2.0) + if node_id in placed: + d = placed[node_id] + return (d.x + d.w / 2.0, d.y + d.h / 2.0) + return None + + kedges: list[KEdge] = [] + for e in sorted(pack.get("knowledge_edges", []), key=lambda e: (e["from"], e["type"], e["to_kind"], e["to"])): + a = _center(e["from"], "doc") + b = _center(e["to"], e["to_kind"]) + if a is not None and b is not None: + kedges.append(KEdge(e["type"], e["from"], e["to"], e["to_kind"], (a, b))) + + width = max([d.x + d.w for d in docs] + [repo_layout.width]) + MARGIN + height = max([d.y + d.h for d in docs] + [repo_layout.height]) + MARGIN + return AtlasLayout(repo_layout, docs, tuple(kedges), width, height) diff --git a/client-plugin/server/src/index_graph/viz/atlas_svg.py b/client-plugin/server/src/index_graph/viz/atlas_svg.py new file mode 100644 index 0000000..aa5c24e --- /dev/null +++ b/client-plugin/server/src/index_graph/viz/atlas_svg.py @@ -0,0 +1,63 @@ +"""Render an AtlasLayout to a self-contained two-layer SVG (repos + docs).""" +from __future__ import annotations + +from xml.sax.saxutils import escape, quoteattr + +from .atlas_layout import AtlasLayout, DocNode, KEdge +from .svg import _node_svg, _edge_svg # reuse repo node + dependency edge renderers +from .theme import THEME, svg_style + + +def _atlas_style() -> str: + t = THEME + return ( + f".docnode rect{{fill:{t.bg};stroke:{t.gold};stroke-dasharray:3 2;}}" + f".docnode text{{font-family:{t.font_mono};fill:{t.ink};font-size:11px;}}" + f".docnode.band rect{{stroke:{t.teal};}}" + f".docnode.sel rect{{stroke:{t.accent};stroke-width:2;stroke-dasharray:none;}}" + f".kedge{{fill:none;stroke-width:1;}}" + f".kedge-describes{{stroke:{t.gold};}}" + f".kedge-links-to{{stroke:{t.ok};stroke-dasharray:4 3;}}" + f".kedge-mentions{{stroke:{t.muted};stroke-dasharray:1 4;opacity:.35;}}" + f".kedge.dim,.node.dim,.docnode.dim,.edge.dim{{opacity:.08;}}" + ) + + +def _doc_svg(d: DocNode) -> str: + cls = "docnode" + ("" if d.describes is not None else " band") + return ( + f"' + f'' + f'{escape(d.title)}' + ) + + +def _kedge_svg(k: KEdge) -> str: + (a, b) = k.points + cls = "kedge kedge-" + k.type + return ( + f"' + ) + + +def render_atlas_svg(atlas: AtlasLayout) -> str: + rl = atlas.repo_layout + defs = ( + '' + f'' + ) + style = f"" + kedges = "".join(_kedge_svg(k) for k in atlas.kedges) + repo_edges = "".join(_edge_svg(e) for e in rl.edges) + repo_nodes = "".join(_node_svg(n) for n in rl.nodes) + doc_nodes = "".join(_doc_svg(d) for d in atlas.docs) + return ( + f'' + f'{defs}{style}{kedges}{repo_edges}{repo_nodes}{doc_nodes}' + ) diff --git a/client-plugin/server/src/index_graph/viz/charts.py b/client-plugin/server/src/index_graph/viz/charts.py new file mode 100644 index 0000000..df8a082 --- /dev/null +++ b/client-plugin/server/src/index_graph/viz/charts.py @@ -0,0 +1,49 @@ +"""Three compact HTML+CSS bar charts derived purely by counting the pack.""" +from __future__ import annotations + +from collections import Counter +from xml.sax.saxutils import escape + +_PRECEDENCE = ("entrypoint", "orchestrator", "hub", "library", "leaf", "isolated") + + +def _primary(roles: list[str]) -> str: + for r in _PRECEDENCE: + if r in roles: + return r + return "isolated" + + +def _bars(pairs: list[tuple[str, int]]) -> str: + top = max((v for _, v in pairs), default=0) or 1 + rows = [] + for label, value in pairs: + pct = round(100 * value / top) + rows.append( + f'
      {escape(label)}' + f'{value}
      ' + ) + return '
      ' + "".join(rows) + "
      " + + +def render_charts(pack: dict, *, include_external: bool = True) -> dict[str, str]: + rels = pack.get("relations", []) + counted_rels = rels if include_external else [r for r in rels if not r.get("external")] + conf = Counter(r.get("confidence", "low") for r in counted_rels) + confidence = _bars([(k, conf.get(k, 0)) for k in ("high", "moderate", "low")]) + + roles = pack.get("roles", {}) + role_counts = Counter(_primary(list(roles.get(r["name"], ()))) for r in pack.get("repos", [])) + role_chart = _bars([(k, role_counts[k]) for k in _PRECEDENCE if role_counts[k]]) + + sal = pack.get("salience", {}) + fan_in = sorted(sal.items(), key=lambda kv: (-kv[1].get("in_degree", 0), kv[0]))[:5] + fan_out = sorted(sal.items(), key=lambda kv: (-kv[1].get("out_degree", 0), kv[0]))[:5] + fanio = ( + '

      Most depended-on

      ' + + _bars([(k, v.get("in_degree", 0)) for k, v in fan_in]) + + '

      Most dependencies

      ' + + _bars([(k, v.get("out_degree", 0)) for k, v in fan_out]) + ) + return {"confidence": confidence, "roles": role_chart, "fanio": fanio} diff --git a/client-plugin/server/src/index_graph/viz/html.py b/client-plugin/server/src/index_graph/viz/html.py new file mode 100644 index 0000000..ca11c08 --- /dev/null +++ b/client-plugin/server/src/index_graph/viz/html.py @@ -0,0 +1,136 @@ +"""Render the hero: a single self-contained interactive dashboard.""" +from __future__ import annotations + +import json +from xml.sax.saxutils import escape + +from .theme import css_variables, ROLE_COLOR, THEME + +_CSS = """ +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink); +font-family:var(--font-body)}main{display:grid;grid-template-columns:1fr 340px; +min-height:100vh}#stage{overflow:auto;padding:1rem}aside{border-left:1px solid +var(--hairline);padding:1rem;font-family:var(--font-mono);font-size:.82rem} +.controls{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:.8rem} +.chip{cursor:pointer;border:1px solid var(--hairline);border-radius:6px; +padding:.2em .5em;background:transparent;color:var(--ink)} +.chip[aria-pressed=true]{background:var(--accent);color:var(--bg)} +input[type=search]{width:100%;padding:.4em;background:transparent;color:var(--ink); +border:1px solid var(--hairline);border-radius:6px;margin-bottom:.6rem} +.node.dim{opacity:.12}.edge.dim{opacity:.05} +.node:focus rect,.node.sel rect{stroke:var(--accent);stroke-width:2} +.row{display:flex;align-items:center;gap:.4rem;margin:.15rem 0} +.lbl{width:6.5rem;opacity:.85}.bar{height:.7rem;background:var(--teal);border-radius:3px} +.num{opacity:.7}h4{margin:.6rem 0 .2rem;color:var(--gold)} +@media(prefers-reduced-motion:reduce){*{transition:none!important}} +@media(max-width:820px){main{grid-template-columns:1fr}aside{border-left:none}} +.tip{position:fixed;pointer-events:none;z-index:9;max-width:24rem;background:var(--bg); +border:1px solid var(--accent);border-radius:6px;padding:.4em .6em;font-family:var(--font-mono); +font-size:.74rem;line-height:1.35}.tip[hidden]{display:none} +.legend{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;margin:.2rem 0 .8rem; +font-family:var(--font-mono);font-size:.72rem;opacity:.85}.legend b{color:var(--gold)} +.leg{display:inline-flex;align-items:center;gap:.3rem}.leg i{width:.8rem;height:.8rem; +border-radius:2px;display:inline-block}.leg i.ln{height:0;width:1.2rem;border-top:2px solid} +.leg i.ln.d5{border-top-style:dashed}.leg i.ln.d2{border-top-style:dotted} +""" + +_JS = """ +const $=s=>document.querySelector(s),$$=s=>[...document.querySelectorAll(s)]; +const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const state={q:'',roles:new Set(),conf:new Set(),ext:true}; +const idx={};DATA.repos.forEach(r=>idx[r.name]=r); +function match(name){const r=idx[name];if(!r)return state.ext; + if(state.q&&!name.toLowerCase().includes(state.q))return false; + const role=(DATA.roles[name]||['isolated'])[0]; + if(state.roles.size&&!state.roles.has(role))return false;return true;} +function apply(){$$('.node').forEach(g=>{g.classList.toggle('dim',!match(g.dataset.name));}); + $$('.edge').forEach(p=>{const on=match(p.dataset.from)&&match(p.dataset.to)&& + (!state.conf.size||state.conf.has(p.className.baseVal.match(/edge-(high|moderate|low)/)?.[1])); + p.classList.toggle('dim',!on);});} +function detail(name){const r=idx[name]||{name,ecosystems:[],markers:[]}; + const outs=DATA.relations.filter(e=>e.from===name); + const ins=DATA.relations.filter(e=>e.to===name); + const sig=e=>(e.signals||[]).map(s=>`${esc(s.file)}${s.line?':'+esc(s.line):''} ${esc(s.kind)}`).join('; '); + $('#detail').innerHTML=`

      ${esc(name)}

      roles: ${esc((DATA.roles[name]||[]).join(', '))||'none'}
      +
      in ${ (DATA.salience[name]||{}).in_degree||0 } · out ${ (DATA.salience[name]||{}).out_degree||0 }
      +

      depends on

      ${outs.map(e=>`
      ${esc(e.target_name)} [${esc(e.confidence)}] ${sig(e)}
      `).join('')||'none'} +

      depended on by

      ${ins.map(e=>`
      ${esc(e.from)} [${esc(e.confidence)}]
      `).join('')||'none'}`;} +const tip=Object.assign(document.createElement('div'),{className:'tip',hidden:true}); +function edgeTip(p,x,y){const sg=JSON.parse(p.getAttribute('data-signals')||'[]'); + const conf=(p.className.baseVal.match(/edge-(high|moderate|low)/)||[])[1]||'declared'; + const ev=sg.map(s=>`${esc(s.file)}${s.line?':'+esc(s.line):''} (${esc(s.kind)})`).join('
      ')||'manifest'; + tip.innerHTML=`${esc(p.dataset.from)} → ${esc(p.dataset.to)} · ${esc(conf)}
      ${ev}`; + tip.hidden=false;tip.style.left=(x+12)+'px';tip.style.top=(y+12)+'px';} +function nbrs(name){const s=new Set([name]);DATA.relations.forEach(e=>{ + if(e.from===name&&e.to)s.add(e.to);if(e.to===name)s.add(e.from);});return s;} +function highlight(name){const s=nbrs(name); + $$('.node').forEach(g=>g.classList.toggle('dim',!s.has(g.dataset.name))); + $$('.edge').forEach(p=>p.classList.toggle('dim',!(s.has(p.dataset.from)&&s.has(p.dataset.to))));} +function wire(){ + $('#search').addEventListener('input',e=>{state.q=e.target.value.toLowerCase();apply();}); + $$('.chip[data-role]').forEach(c=>c.addEventListener('click',()=>{ + const r=c.dataset.role;state.roles.has(r)?state.roles.delete(r):state.roles.add(r); + c.setAttribute('aria-pressed',state.roles.has(r));apply();})); + $$('.node').forEach(g=>{const pick=()=>{$$('.node').forEach(n=>n.classList.remove('sel')); + g.classList.add('sel');detail(g.dataset.name);}; + g.addEventListener('click',pick);g.addEventListener('keydown',e=>{if(e.key==='Enter')pick();});}); + document.body.appendChild(tip); + $$('.edge').forEach(p=>{p.addEventListener('mousemove',e=>edgeTip(p,e.clientX,e.clientY)); + p.addEventListener('mouseleave',()=>tip.hidden=true);}); + $$('.node').forEach(g=>{g.addEventListener('mouseenter',()=>highlight(g.dataset.name)); + g.addEventListener('mouseleave',apply);}); + apply();} +document.addEventListener('DOMContentLoaded',wire); +""" + + +def _legend() -> str: + roles = "".join( + f'{escape(r)}' + for r, c in ROLE_COLOR.items() if r != "external") + edges = ( + f'high' + f'moderate' + f'low / external' + f'cycle') + return (f'
      roles{roles}' + f'edges{edges}
      ') + + +def _salience_audit_panel(pack: dict) -> str: + entries = pack.get("salience_audit", []) + rows = "".join( + f'
      {escape(str(e.get("node", "")))}' + f' [{escape(str(e.get("kind", "")))}]' + f': {escape(str(e.get("note", "")))}
      ' + for e in entries + ) + body = rows if rows else '
      none
      ' + return f"

      salience audit

      {body}" + + +def render_html(pack: dict, *, svg: str, charts: dict[str, str]) -> str: + data = json.dumps(pack, sort_keys=True, separators=(",", ":")).replace("<", "\\u003c") + roles = sorted({(rs or ["isolated"])[0] for rs in pack.get("roles", {}).values()}) + chips = "".join( + f'' for r in roles + ) + audit_panel = _salience_audit_panel(pack) + return ( + "" + '' + '' + "index · context" + f"" + '
      ' + f'
      {chips}
      ' + f"{_legend()}{svg}
      " + '
      ' + f"" + "" + ) diff --git a/client-plugin/server/src/index_graph/viz/layout.py b/client-plugin/server/src/index_graph/viz/layout.py new file mode 100644 index 0000000..0f956bb --- /dev/null +++ b/client-plugin/server/src/index_graph/viz/layout.py @@ -0,0 +1,215 @@ +"""Deterministic layered-by-role layout for the dependency graph.""" +from __future__ import annotations + +from dataclasses import dataclass, replace + +ROLE_PRECEDENCE = ("entrypoint", "orchestrator", "hub", "library", "leaf", "isolated") +_EXTERNAL_LAYER = len(ROLE_PRECEDENCE) +_SWEEPS = 3 + +LAYER_GAP = 140.0 +NODE_H = 44.0 +NODE_GAP = 28.0 +CHAR_W = 8.5 +PAD_X = 24.0 +MARGIN = 40.0 +BACK_BOW = 60.0 # right-side headroom a back-edge bows into; reserved in the canvas + + +@dataclass(frozen=True) +class LaidNode: + name: str + role: str + roles: tuple[str, ...] + layer: int + order: int = 0 + x: float = 0.0 + y: float = 0.0 + w: float = 0.0 + h: float = 0.0 + external: bool = False + in_degree: int = 0 + out_degree: int = 0 + hub: bool = False + in_cycle: bool = False + + +@dataclass(frozen=True) +class LaidEdge: + from_repo: str + to_repo: str + confidence: str + external: bool + back_edge: bool = False + in_cycle: bool = False + points: tuple[tuple[float, float], ...] = () + signals: tuple[dict, ...] = () + + +@dataclass(frozen=True) +class LayoutModel: + nodes: tuple[LaidNode, ...] + edges: tuple[LaidEdge, ...] + # Lists only PRESENT layers in ascending order; NOT index-aligned to ROLE_PRECEDENCE. + layers: tuple[str, ...] + width: float = 0.0 + height: float = 0.0 + + +def _primary_role(roles: list[str]) -> str: + for role in ROLE_PRECEDENCE: + if role in roles: + return role + return "isolated" + + +def _build_nodes(pack: dict, include_external: bool) -> list[LaidNode]: + roles = pack.get("roles", {}) + salience = pack.get("salience", {}) + cycle_members = {n for c in pack.get("cycles", []) for n in c} + nodes: list[LaidNode] = [] + for repo in pack.get("repos", []): + name = repo["name"] + rs = tuple(roles.get(name, ())) + primary = _primary_role(list(rs)) + sal = salience.get(name, {}) + nodes.append( + LaidNode( + name=name, + role=primary, + roles=rs, + layer=ROLE_PRECEDENCE.index(primary), + in_degree=int(sal.get("in_degree", 0)), + out_degree=int(sal.get("out_degree", 0)), + hub=bool(sal.get("hub", False)), + in_cycle=name in cycle_members, + ) + ) + if include_external: + seen = set() + for rel in pack.get("relations", []): + if rel.get("external") and rel["target_name"] not in seen: + seen.add(rel["target_name"]) + nodes.append( + LaidNode( + name=rel["target_name"], + role="external", + roles=("external",), + layer=_EXTERNAL_LAYER, + external=True, + ) + ) + return nodes + + +def _build_edges(pack: dict, names: set[str], include_external: bool) -> list[LaidEdge]: + edges: list[LaidEdge] = [] + for rel in pack.get("relations", []): + external = bool(rel.get("external")) + target = rel["target_name"] if external else rel["to"] + if external and not include_external: + continue + if rel["from"] not in names: + continue + if target not in names: + continue + edges.append( + LaidEdge( + from_repo=rel["from"], + to_repo=target, + confidence=rel.get("confidence", "low"), + external=external, + in_cycle=rel.get("in_cycle", False), + signals=tuple(rel.get("signals", ())), + ) + ) + return edges + + +def _order_within_layers(nodes: list[LaidNode], edges: list[LaidEdge]) -> list[LaidNode]: + by_layer: dict[int, list[LaidNode]] = {} + for n in nodes: + by_layer.setdefault(n.layer, []).append(n) + # initial stable order: alphabetical by name + for layer in by_layer.values(): + layer.sort(key=lambda n: n.name) + # adjacency for barycentre + nbrs: dict[str, list[str]] = {n.name: [] for n in nodes} + for e in edges: + nbrs.setdefault(e.from_repo, []).append(e.to_repo) + nbrs.setdefault(e.to_repo, []).append(e.from_repo) + for _ in range(_SWEEPS): + pos = {n.name: i for layer in by_layer.values() for i, n in enumerate(layer)} + for layer in by_layer.values(): + def bary(n: LaidNode) -> tuple[float, str]: + ns = [pos[m] for m in nbrs.get(n.name, []) if m in pos] + return (sum(ns) / len(ns) if ns else pos[n.name], n.name) + layer.sort(key=bary) + ordered: list[LaidNode] = [] + for layer_idx in sorted(by_layer): + for order, n in enumerate(by_layer[layer_idx]): + ordered.append(replace(n, order=order)) + return ordered + + +def _place(nodes: list[LaidNode]) -> tuple[list[LaidNode], float, float]: + by_layer: dict[int, list[LaidNode]] = {} + for n in nodes: + by_layer.setdefault(n.layer, []).append(n) + for layer in by_layer.values(): + layer.sort(key=lambda n: n.order) + widths = { + idx: sum(len(n.name) * CHAR_W + PAD_X for n in layer) + NODE_GAP * (len(layer) - 1) + for idx, layer in by_layer.items() + } + content_w = max(widths.values(), default=0.0) + canvas_w = content_w + 2 * MARGIN + BACK_BOW # reserve right headroom for back-edge bows + placed: dict[str, LaidNode] = {} + for layer_idx, layer in by_layer.items(): + cursor = MARGIN + (content_w - widths[layer_idx]) / 2 # centre within the content band + y = MARGIN + layer_idx * LAYER_GAP + for n in layer: + w = len(n.name) * CHAR_W + PAD_X + placed[n.name] = replace(n, x=cursor, y=y, w=w, h=NODE_H) + cursor += w + NODE_GAP + ordered = [placed[n.name] for n in nodes] + layer_count = (max(by_layer) + 1) if by_layer else 0 + canvas_h = MARGIN * 2 + max(layer_count - 1, 0) * LAYER_GAP + NODE_H + return ordered, canvas_w, canvas_h + + +def _route(edges: list[LaidEdge], nodes: list[LaidNode]) -> list[LaidEdge]: + by_name = {n.name: n for n in nodes} + routed: list[LaidEdge] = [] + for e in edges: + src, dst = by_name.get(e.from_repo), by_name.get(e.to_repo) + if src is None or dst is None: + continue + back = dst.layer <= src.layer + sx, sy = src.x + src.w / 2, src.y + src.h + tx, ty = dst.x + dst.w / 2, dst.y + if back: # route the upward/lateral return through the reserved right headroom + sy = src.y + src.h / 2 + ty = dst.y + dst.h / 2 + pts = ((sx, sy), (sx + BACK_BOW, sy), (tx + BACK_BOW, ty), (tx, ty)) + else: + dy = (ty - sy) * 0.4 + pts = ((sx, sy), (sx, sy + dy), (tx, ty - dy), (tx, ty)) + routed.append(replace(e, back_edge=back, points=pts)) + return routed + + +def build_layout(pack: dict, *, include_external: bool = True) -> LayoutModel: + nodes = _build_nodes(pack, include_external) + names = {n.name for n in nodes} + edges = _build_edges(pack, names, include_external) + nodes = _order_within_layers(nodes, edges) + nodes, width, height = _place(nodes) + edges = _route(edges, nodes) + present = sorted({n.layer for n in nodes}) + labels = tuple( + (ROLE_PRECEDENCE[i] if i < len(ROLE_PRECEDENCE) else "external") for i in present + ) + return LayoutModel( + nodes=tuple(nodes), edges=tuple(edges), layers=labels, width=width, height=height + ) diff --git a/client-plugin/server/src/index_graph/viz/lens_html.py b/client-plugin/server/src/index_graph/viz/lens_html.py new file mode 100644 index 0000000..167ffae --- /dev/null +++ b/client-plugin/server/src/index_graph/viz/lens_html.py @@ -0,0 +1,219 @@ +"""The Context Lens: a self-contained page that shows what an agent's context +budget actually holds, and what it drops and why, as you move the budget. + +No other map tool renders this. Obsidian shows notes; graph tools show nodes. +This shows a SELECTION under a token budget: one ordered stack of repos in the +exact rank the greedy fill considers them, with a budget frontier that slides +as you drag the budget. Repos above the frontier are RETAINED; the moment one +crosses below it, it is OMITTED and carries its failure code +(budget_exceeded / outside_focus_or_budget). The replay runs the exact greedy +rule from context/lens.py in the browser over the pack's own numbers, so the +frontier the slider draws is the frontier the CLI would emit. Zero runtime +deps, deterministic, offline; the receipt hash is printed on the page. + +Brand: ceramic bg, ink text, one iris accent, mono for receipts — cohesive +with the index atlas. Motion conveys state change only (a repo crossing the +frontier), with a reduced-motion fallback. +""" +from __future__ import annotations + +import html +import json + +_CSS = """ +:root{ + --bg:#f4f3ef;--panel:#fbfaf7;--ink:#0b0c0e;--soft:#2f3238;--muted:#565a62; + --hair:rgba(11,12,14,.12);--hair2:rgba(11,12,14,.07); + --iris:#4636e8;--iris-wash:rgba(70,54,232,.08);--iris-line:rgba(70,54,232,.35); + --drop:#8a5a12;--in-ink:#0b0c0e; + --mono:ui-monospace,SFMono-Regular,Consolas,"SF Mono",monospace; + --body:Arial,Helvetica,"Helvetica Neue",sans-serif; + --e:cubic-bezier(.22,1,.36,1)} +*{box-sizing:border-box} +html{-webkit-text-size-adjust:100%} +body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--body); + font-size:16px;line-height:1.5;-webkit-font-smoothing:antialiased} +.wrap{max-width:1000px;margin:0 auto;padding:2.2rem 1.5rem 3rem} +header{margin-bottom:1.6rem} +.eyebrow{font:600 .72rem/1 var(--mono);letter-spacing:.14em;color:var(--iris); + text-transform:uppercase;margin:0 0 .55rem} +h1{font:600 1.7rem/1.15 var(--body);margin:0 0 .5rem;letter-spacing:-.02em;text-wrap:balance; + display:flex;align-items:baseline;gap:.6rem;flex-wrap:wrap} +.sub{color:var(--soft);font-size:.92rem;max-width:68ch;margin:0} +.sub code{font:.85em var(--mono);background:var(--hair2);padding:.05em .35em;border-radius:4px} +.verdict{font:600 .68rem/1 var(--mono);letter-spacing:.06em;padding:.35em .6em;border-radius:999px; + border:1px solid;vertical-align:middle;white-space:nowrap;transition:color .2s,border-color .2s,background .2s} +.verdict[data-v=MATCH]{color:#1f6b45;border-color:rgba(31,107,69,.4);background:rgba(31,107,69,.07)} +.verdict[data-v=UNVERIFIABLE]{color:var(--drop);border-color:rgba(138,90,18,.4);background:rgba(138,90,18,.08)} + +.panel{background:var(--panel);border:1px solid var(--hair);border-radius:14px; + box-shadow:0 1px 0 rgba(11,12,14,.03),0 12px 30px -22px rgba(11,12,14,.35);overflow:hidden} +.rail{display:grid;grid-template-columns:1fr auto;gap:1rem 1.4rem;align-items:center; + padding:1.1rem 1.3rem;border-bottom:1px solid var(--hair2)} +.slider{grid-column:1/-1;display:flex;align-items:center;gap:1rem} +.slider label{font:600 .74rem/1 var(--mono);letter-spacing:.04em;color:var(--muted);white-space:nowrap} +input[type=range]{flex:1;height:26px;accent-color:var(--iris);cursor:pointer} +input[type=range]:focus-visible{outline:2px solid var(--iris);outline-offset:4px;border-radius:4px} +.readout{display:flex;align-items:baseline;gap:.5rem;font:var(--mono)} +.readout .big{font:600 1.35rem/1 var(--mono);color:var(--iris);letter-spacing:-.01em; + font-variant-numeric:tabular-nums} +.readout .of{font:.8rem var(--mono);color:var(--muted)} +.legend{display:flex;gap:1.1rem;font:600 .72rem/1 var(--mono);letter-spacing:.03em;flex-wrap:wrap} +.legend b{font-weight:600} +.legend .k{display:inline-flex;align-items:center;gap:.4em;color:var(--muted)} +.legend .dot{width:.7em;height:.7em;border-radius:2px;display:inline-block} +.legend .dot.in{background:var(--iris)} +.legend .dot.out{background:transparent;border:1px solid var(--muted)} +.legend .n{color:var(--ink);font-variant-numeric:tabular-nums} + +.stack{list-style:none;margin:0;padding:.3rem 0} +.frontier{position:relative;height:0;margin:0 1.3rem;border-top:1.5px dashed var(--iris-line); + transition:margin-top .28s var(--e)} +.frontier::after{content:"budget frontier — " attr(data-tok) " tok";position:absolute;right:0;top:-.55rem; + font:600 .64rem/1 var(--mono);letter-spacing:.05em;color:var(--iris);background:var(--panel); + padding:.2em .5em;border:1px solid var(--iris-line);border-radius:999px} +.item{display:grid;grid-template-columns:1.7rem 1fr auto;align-items:center;gap:.2rem .9rem; + padding:.62rem 1.3rem;position:relative;transition:background .24s var(--e),opacity .24s var(--e)} +.item::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--iris); + transform:scaleY(0);transform-origin:top;transition:transform .26s var(--e)} +.item.in::before{transform:scaleY(1)} +.item+.item{border-top:1px solid var(--hair2)} +.item .rank{font:600 .72rem/1 var(--mono);color:var(--muted);font-variant-numeric:tabular-nums;text-align:right} +.item .name{font:600 .98rem/1.25 var(--body);letter-spacing:-.01em;transition:color .24s} +.item .meta{grid-column:2;font:.74rem/1.4 var(--mono);color:var(--muted); + display:flex;gap:.85rem;flex-wrap:wrap;margin-top:.12rem} +.item .desc{grid-column:2;font:.82rem/1.4 var(--body);color:var(--soft);margin-top:.15rem; + max-width:64ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.item .cost{font:600 .82rem/1 var(--mono);color:var(--ink);font-variant-numeric:tabular-nums;white-space:nowrap;text-align:right} +.item .code{color:var(--drop);font-weight:600} +.item.in{background:var(--iris-wash)} +.item.out{opacity:.62} +.item.out .name{color:var(--soft)} +.item.cross{animation:cross .5s var(--e)} +@keyframes cross{0%{background:rgba(70,54,232,.22)}100%{background:var(--iris-wash)}} + +footer{margin-top:1.3rem;display:flex;flex-direction:column;gap:.35rem; + font:.72rem/1.5 var(--mono);color:var(--muted);word-break:break-all} +footer .lbl{color:var(--soft);font-weight:600} +@media(max-width:640px){ + .wrap{padding:1.5rem 1rem 2rem}h1{font-size:1.4rem} + .item .desc{white-space:normal} + .rail{grid-template-columns:1fr}.legend{justify-content:flex-start} +} +@media(prefers-reduced-motion:reduce){ + *{transition:none!important;animation:none!important} +} +""" + +_JS = r""" +const P=LENS,ORDER=P.replay.order,BASE=P.replay.base_tokens; +const $=s=>document.querySelector(s); +const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const OUTSIDE={};(P.envelope.omitted||[]).forEach(o=>{if(o.reason!=='budget_exceeded')OUTSIDE[o.name]=o.reason;}); +function replay(budget){ // MIRRORS context/lens.py replay_retained + const keep=new Set();let approx=BASE; + for(const o of ORDER){if(approx+o.cost<=budget||keep.size===0){keep.add(o.name);approx+=o.cost;}} + return{keep,approx}; +} +// build every row ONCE, in rank order; state is a class toggle so rows glide, never re-mount +const stack=$('#stack'); +ORDER.forEach((o,i)=>{ + const li=document.createElement('li');li.className='item';li.dataset.n=o.name; + const meta=[`in ${o.salience.in_degree}`,`out ${o.salience.out_degree}`, + (o.roles&&o.roles.length?o.roles.join(' · '):'')].filter(Boolean).map(esc).join(''); + li.innerHTML=`${String(i+1).padStart(2,'0')}`+ + `${esc(o.name)}`+ + `~${o.cost}`+ + `${meta}`+ + (o.description?`${esc(o.description)}`:''); + stack.appendChild(li); + // the frontier divider lives right after the last row; moved via order-index + o._el=li; +}); +const frontier=document.createElement('div');frontier.className='frontier';frontier.id='frontier'; +let prevIn=new Set(); +function setCode(el,name,inCtx){ + const cost=el.querySelector('.cost'); + if(inCtx){cost.textContent='~'+cost.dataset.cost;cost.classList.remove('code');} + else{cost.innerHTML=`${esc(OUTSIDE[name]||'budget_exceeded')}`;} +} +function render(budget){ + const{keep,approx}=replay(budget); + let lastIn=-1; + ORDER.forEach((o,i)=>{ + const inCtx=keep.has(o.name),el=o._el; + el.classList.toggle('in',inCtx);el.classList.toggle('out',!inCtx); + if(inCtx){lastIn=i;if(!prevIn.has(o.name)){el.classList.remove('cross');void el.offsetWidth;el.classList.add('cross');}} + setCode(el,o.name,inCtx); + }); + // slide the frontier to sit just below the last retained row + const anchor=lastIn>=0?ORDER[lastIn]._el:null; + if(anchor&&anchor.nextSibling!==frontier)stack.insertBefore(frontier,anchor.nextSibling); + else if(!anchor&&stack.firstChild!==frontier)stack.insertBefore(frontier,stack.firstChild); + frontier.dataset.tok=budget; + prevIn=keep; + const inN=keep.size,outN=ORDER.length-inN; + $('#in-n').textContent=inN;$('#out-n').textContent=outN; + $('#used').textContent=approx;$('#bud').textContent=budget;$('#bval').textContent=budget; + // MIRRORS context/lens.py replay_verdict: any scoped candidate dropped -> UNVERIFIABLE + const verdict=inNrender(+slider.value)); +render(+slider.value); +""" + + +def render_lens_html(lens: dict) -> str: + env = lens["envelope"] + order = lens["replay"]["order"] + costs = [o["cost"] for o in order] + total = sum(costs) + lens["replay"]["base_tokens"] + built = env["budget"]["token_budget"] + lo = max(1, min(costs) if costs else 1) + hi = max(total, built) + focus = env["focus"]["repo"] + scope = (f"focused on {html.escape(str(focus))}" + if focus else "across the whole workspace") + root = html.escape(str(env["root"])) + data = json.dumps(lens, separators=(",", ":")) + return f""" + +Context Lens — {root} + +
      +
      +

      index · context lens

      +

      What fits in the context budget +MATCH

      +

      Every repo {root} maps, ranked in the order an agent's context would +fill, {scope}. Drag the budget and the frontier slides: repos above it are RETAINED, +the ones below are OMITTED and carry their failure code. The slider replays the exact +selection index context-envelope emits, so what you see is what the agent gets.

      +
      + +
      +
      +
      + + +{built} +
      +
      0/ {built} tokens used
      +
      +RETAINED 0 +OMITTED 0 +
      +
      +
        +
        + +
        +
        receipt sha256  {html.escape(lens['receipt_sha256'])}
        +
        re-check  {html.escape(env['recheck']['command'])}  ·  graph-pack {html.escape(env['recheck']['graph_pack_sha256'][:16])}…
        +
        +
        + +""" diff --git a/client-plugin/server/src/index_graph/viz/manifest.py b/client-plugin/server/src/index_graph/viz/manifest.py new file mode 100644 index 0000000..7f845c2 --- /dev/null +++ b/client-plugin/server/src/index_graph/viz/manifest.py @@ -0,0 +1,43 @@ +"""The context manifest: the defined handoff seam for downstream consumers.""" +from __future__ import annotations + +import hashlib +import json +from collections import Counter + + +def _sha(b: bytes) -> str: + return hashlib.sha256(b).hexdigest() + + +def render_manifest(pack: dict, *, artifacts: dict, meta: dict) -> dict: + snapshot = json.dumps(pack, sort_keys=True, separators=(",", ":")).encode("utf-8") + role_counts = Counter( + (rs or ["isolated"])[0] for rs in pack.get("roles", {}).values() + ) + renders = { + key: {"path": path, "sha256": _sha(data)} + for key, (path, data) in artifacts.items() + if key in ("mermaid", "svg", "html") + } + out = { + "schema_version": "1", + "generated": { + "tool": "index", + "version": meta.get("version", ""), + "commit": meta.get("commit"), + "root": meta.get("root", ""), + }, + "graph": { + "node_count": len(pack.get("repos", [])), + "edge_count": len(pack.get("relations", [])), + "roles": dict(role_counts), + "snapshot_sha256": _sha(snapshot), + }, + "renders": renders, + "receipts": {"present": False}, + } + if "context" in artifacts: + path, data = artifacts["context"] + out["context_pack"] = {"path": path, "sha256": _sha(data)} + return out diff --git a/client-plugin/server/src/index_graph/viz/mermaid.py b/client-plugin/server/src/index_graph/viz/mermaid.py new file mode 100644 index 0000000..2eab495 --- /dev/null +++ b/client-plugin/server/src/index_graph/viz/mermaid.py @@ -0,0 +1,70 @@ +"""Render the pack to a Mermaid flowchart (Mermaid performs its own layout).""" +from __future__ import annotations + +import re + +from .theme import ROLE_COLOR + +_PRECEDENCE = ("entrypoint", "orchestrator", "hub", "library", "leaf", "isolated") + + +def _primary(roles: list[str]) -> str: + for r in _PRECEDENCE: + if r in roles: + return r + return "isolated" + + +def _id_map(names): + out, used = {}, set() + for name in names: # names arrive in a stable (sorted) order + base = "n_" + re.sub(r"[^0-9A-Za-z]", "_", name) + ident, i = base, 1 + while ident in used: + ident, i = f"{base}_{i}", i + 1 + used.add(ident) + out[name] = ident + return out + + +def render_mermaid(pack: dict, *, include_external: bool = True) -> str: + roles = pack.get("roles", {}) + lines = ["flowchart TD"] + # deterministic node declarations + internal = sorted(r["name"] for r in pack.get("repos", [])) + externals = ( + sorted({r["target_name"] for r in pack.get("relations", []) if r.get("external")}) + if include_external + else [] + ) + ids = _id_map(internal + externals) + node_role: dict[str, str] = {} + for name in internal: + primary = _primary(list(roles.get(name, ()))) + node_role[name] = primary + lines.append(f' {ids[name]}["{name}"]') + for name in externals: + node_role[name] = "external" + lines.append(f' {ids[name]}(("{name}"))') + # edges, deterministic order (skip external edges when include_external=False) + rels = sorted( + pack.get("relations", []), + key=lambda r: (r["from"], (r["target_name"] if r.get("external") else r["to"]), r.get("confidence", "")), + ) + for r in rels: + if r.get("external") and not include_external: + continue + target = r["target_name"] if r.get("external") else r["to"] + kinds = {s["kind"] for s in r.get("signals", []) if s.get("kind")} + conf = r.get("confidence", "low") + if kinds: + label = f'{conf} ({"+".join(sorted(kinds))})' + lines.append(f' {ids[r["from"]]} -->|{label}| {ids[target]}') + else: + lines.append(f' {ids[r["from"]]} -->|{conf}| {ids[target]}') + # classDefs + assignments + for role, color in ROLE_COLOR.items(): + lines.append(f" classDef {role} fill:{color},stroke:#0d1b1c,color:#e9e2d0;") + for name in internal + externals: + lines.append(f" class {ids[name]} {node_role[name]};") + return "\n".join(lines) + "\n" diff --git a/client-plugin/server/src/index_graph/viz/svg.py b/client-plugin/server/src/index_graph/viz/svg.py new file mode 100644 index 0000000..3574513 --- /dev/null +++ b/client-plugin/server/src/index_graph/viz/svg.py @@ -0,0 +1,73 @@ +"""Render a LayoutModel to a self-contained SVG network graph.""" +from __future__ import annotations + +import json +from xml.sax.saxutils import escape, quoteattr + +from .layout import LayoutModel +from .theme import THEME, svg_style + + +def _path_d(points: tuple[tuple[float, float], ...]) -> str: + (m, c1, c2, end) = points + return ( + f"M{m[0]:.2f},{m[1]:.2f} " + f"C{c1[0]:.2f},{c1[1]:.2f} {c2[0]:.2f},{c2[1]:.2f} {end[0]:.2f},{end[1]:.2f}" + ) + + +def _edge_svg(edge) -> str: + classes = ["edge", f"edge-{edge.confidence}"] + if edge.external: + classes.append("edge-external") + if edge.back_edge: + classes.append("edge-back") + if getattr(edge, "in_cycle", False): + classes.append("edge-cycle") + return ( + (lambda sig: ( + f'' + ))(quoteattr(json.dumps(list(edge.signals), sort_keys=True))) + if edge.points + else "" + ) + + +def _node_svg(node) -> str: + label = escape(node.name) + node_classes = "node role-" + node.role + (" cycle" if getattr(node, "in_cycle", False) else "") + return ( + f'' + f'' + f'{label}' + f"{label}: {escape(node.role)}" + f"" + ) + + +def render_svg(layout: LayoutModel) -> str: + defs = ( + '' + f'' + ) + style = f"" + bg = f'' + edges = "".join(_edge_svg(e) for e in layout.edges) + nodes = "".join(_node_svg(n) for n in layout.nodes) + return ( + f'' + f"{defs}{style}{bg}{edges}{nodes}" + ) diff --git a/client-plugin/server/src/index_graph/viz/theme.py b/client-plugin/server/src/index_graph/viz/theme.py new file mode 100644 index 0000000..53e6a67 --- /dev/null +++ b/client-plugin/server/src/index_graph/viz/theme.py @@ -0,0 +1,65 @@ +"""Project Telos palette + font tokens, shared by every renderer.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Theme: + bg: str = "#f4f3ef" + ink: str = "#0b0c0e" + accent: str = "#4636e8" + teal: str = "#585c64" + gold: str = "#0b0c0e" + ok: str = "#2f3238" + muted: str = "#585c64" + alert: str = "#d9544d" + hairline: str = "rgba(11,12,14,.14)" + font_body: str = "Arial,Helvetica,sans-serif" + font_mono: str = "ui-monospace,SFMono-Regular,Consolas,monospace" + + +THEME = Theme() + +# role -> fill colour (deterministic mapping; unknown roles fall back to muted) +ROLE_COLOR = { + "entrypoint": THEME.accent, + "orchestrator": THEME.gold, + "hub": THEME.ok, + "library": THEME.teal, + "leaf": THEME.muted, + "isolated": THEME.muted, + "external": THEME.hairline, +} + + +def css_variables() -> str: + t = THEME + return ( + ":root{" + f"--bg:{t.bg};--ink:{t.ink};--accent:{t.accent};--teal:{t.teal};" + f"--gold:{t.gold};--ok:{t.ok};--muted:{t.muted};--hairline:{t.hairline};" + f"--font-body:{t.font_body};--font-mono:{t.font_mono};" + "}" + ) + + +def svg_style() -> str: + t = THEME + roles = "".join( + f".role-{role} rect{{fill:{color};}}" for role, color in ROLE_COLOR.items() + ) + return ( + f"svg{{background-color:{t.bg};}}" + f"text{{font-family:{t.font_mono};fill:{t.ink};}} /* labels are identifiers, use monospace not font-body */" + f"rect{{stroke:{t.hairline};}}" + f"{roles}" + f".edge{{fill:none;stroke:{t.muted};}}" + f".edge-high{{stroke:{t.ok};}}" + f".edge-moderate{{stroke:{t.gold};stroke-dasharray:5 3;}}" + f".edge-low{{stroke:{t.muted};stroke-dasharray:2 3;}}" + f".edge-external{{stroke:{t.hairline};}}" + f".edge-back{{stroke:{t.accent};}}" + f".edge-cycle{{stroke:{t.alert};stroke-width:2.4;stroke-dasharray:none;}}" + f".node.cycle rect{{stroke:{t.alert};stroke-width:2.4;}}" + ) diff --git a/client-plugin/server/src/index_graph/viz/workbench_assets.py b/client-plugin/server/src/index_graph/viz/workbench_assets.py new file mode 100644 index 0000000..8b60387 --- /dev/null +++ b/client-plugin/server/src/index_graph/viz/workbench_assets.py @@ -0,0 +1,174 @@ +"""Workbench CSS (string constant embedded into the self-contained HTML).""" +from __future__ import annotations + +WB_CSS = """ +:root{ + --bg:#f4f3ef;--panel:#fbfaf7;--ink:#0b0c0e;--soft:#2f3238;--muted:#565a62; + --hair:rgba(11,12,14,.12);--hair2:rgba(11,12,14,.07); + --iris:#4636e8;--iris-wash:rgba(70,54,232,.08);--iris-line:rgba(70,54,232,.35); + --warn:#8a5a12;--ok:#1f6b45; + --mono:ui-monospace,SFMono-Regular,Consolas,"SF Mono",monospace; + --body:Arial,Helvetica,"Helvetica Neue",sans-serif; + --e:cubic-bezier(.22,1,.36,1); + --z-drop:10;--z-palette:40;--z-toast:50} +*{box-sizing:border-box} +body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--body); + font-size:15px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden} +button{font:inherit;color:inherit;background:none;border:none;cursor:pointer} +.app{display:grid;grid-template-rows:auto 1fr;height:100vh} + +.top{display:flex;align-items:center;gap:.9rem;padding:.6rem 1rem; + border-bottom:1px solid var(--hair);background:var(--panel)} +.brand{font:600 .8rem/1 var(--mono);letter-spacing:.12em;color:var(--iris); + text-transform:uppercase;white-space:nowrap} +.modes{display:flex;gap:.15rem;background:var(--hair2);border-radius:8px;padding:.18rem} +.mode{font:600 .74rem/1 var(--mono);letter-spacing:.03em;padding:.42em .8em;border-radius:6px; + color:var(--muted);transition:color .15s,background .15s} +.mode[aria-pressed=true]{background:var(--panel);color:var(--ink); + box-shadow:0 1px 2px rgba(11,12,14,.12)} +.mode:focus-visible{outline:2px solid var(--iris);outline-offset:2px} +.searchbtn{display:flex;align-items:center;gap:.6rem;margin-left:auto; + font:.78rem var(--mono);color:var(--muted);border:1px solid var(--hair); + border-radius:8px;padding:.42em .7em;min-width:14rem;justify-content:space-between} +.searchbtn kbd{font:600 .68rem var(--mono);border:1px solid var(--hair); + border-radius:4px;padding:.1em .4em;background:var(--hair2)} +.basketbtn{position:relative;font:600 .74rem var(--mono);border:1px solid var(--hair); + border-radius:8px;padding:.45em .8em;transition:border-color .15s} +.basketbtn[data-n]:not([data-n="0"]){border-color:var(--iris-line);color:var(--iris)} +.basketbtn .n{font-variant-numeric:tabular-nums} + +.main{display:grid;grid-template-columns:1fr 380px;min-height:0} +.stage{overflow:auto;position:relative;min-width:0} +.stage:focus-visible{outline:2px solid var(--iris);outline-offset:-2px} +.view{display:none;padding:1.1rem 1.3rem} +.view.on{display:block} +#view-map.on{display:block;padding:0;height:100%;overflow:hidden} +#map-stage{height:100%;overflow:hidden;position:relative} +#map-stage svg{cursor:grab;display:block;width:100%;height:100%} +#map-stage.grabbing svg{cursor:grabbing} +.maptools{position:absolute;top:.7rem;left:.9rem;display:flex;gap:.4rem;z-index:var(--z-drop)} +.maptools .chip,.chip{font:600 .72rem var(--mono);border:1px solid var(--hair); + background:var(--panel);border-radius:6px;padding:.32em .6em;color:var(--soft)} +.chip[aria-pressed=true]{background:var(--iris);border-color:var(--iris);color:#fff} +.legendbar{position:absolute;bottom:.7rem;left:.9rem;display:flex;gap:.9rem;z-index:var(--z-drop); + font:600 .68rem var(--mono);color:var(--muted);background:var(--panel); + border:1px solid var(--hair);border-radius:6px;padding:.35em .7em} +.lg{display:inline-flex;align-items:center;gap:.35em} +.lg i{width:.65em;height:.65em;border-radius:2px;display:inline-block} + +aside{border-left:1px solid var(--hair);background:var(--panel);overflow:auto; + padding:1rem 1.1rem;font-size:.88rem} +aside h3{font:600 1.05rem/1.2 var(--body);margin:.1rem 0 .1rem;letter-spacing:-.01em; + display:flex;align-items:baseline;gap:.5rem;flex-wrap:wrap} +aside h3 small{font:600 .66rem var(--mono);color:var(--muted);letter-spacing:.08em;text-transform:uppercase} +aside h4{font:600 .7rem var(--mono);letter-spacing:.08em;text-transform:uppercase; + color:var(--muted);margin:1rem 0 .35rem} +.pinrow{margin:.4rem 0 .2rem} +.pin{font:600 .72rem var(--mono);border:1px solid var(--hair);border-radius:6px; + padding:.3em .65em;transition:all .15s} +.pin[aria-pressed=true]{background:var(--iris);border-color:var(--iris);color:#fff} +.kv{font:.76rem var(--mono);color:var(--soft);display:flex;gap:.9rem;flex-wrap:wrap;margin:.2rem 0} +.kv b{color:var(--ink);font-variant-numeric:tabular-nums} +.dep{border:1px solid var(--hair2);border-radius:8px;padding:.4rem .55rem;margin:.3rem 0} +.dep .to{font:600 .85rem var(--body)} +.dep .conf{font:.68rem var(--mono);color:var(--muted);margin-left:.4em} +.dep .cyc{font:600 .66rem var(--mono);color:var(--warn);margin-left:.4em} +.ev{font:.72rem var(--mono);color:var(--muted);margin-top:.15rem} +.ev a,.navlink{color:var(--iris);cursor:pointer;text-decoration:none} +.navlink:hover,.ev a:hover{text-decoration:underline} +.fp{font:.7rem var(--mono);color:var(--muted);word-break:break-all} +.md{font-size:.9rem;line-height:1.55;border-top:1px solid var(--hair2);margin-top:.7rem;padding-top:.7rem} +.md pre{background:var(--hair2);border-radius:6px;padding:.6em;overflow:auto;font-size:.8rem} +.md table{border-collapse:collapse}.md th,.md td{border:1px solid var(--hair);padding:.25em .55em} +.md .wikilink{color:var(--iris);cursor:pointer} +.md img{max-width:100%} +.crumbs{font:.7rem var(--mono);color:var(--muted);margin-bottom:.5rem;min-height:1.1em} +.crumbs a{color:var(--iris);cursor:pointer} + +.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:.8rem;margin:.9rem 0} +.stat{border:1px solid var(--hair);border-radius:10px;padding:.7rem .85rem;background:var(--panel)} +.stat .n{font:600 1.5rem/1.1 var(--mono);color:var(--ink);font-variant-numeric:tabular-nums} +.stat .l{font:600 .68rem var(--mono);letter-spacing:.06em;text-transform:uppercase;color:var(--muted)} +.stat.iris .n{color:var(--iris)} +.hero{max-width:72ch} +.hero h2{font:600 1.5rem/1.2 var(--body);letter-spacing:-.02em;margin:.2rem 0 .4rem;text-wrap:balance} +.hero p{color:var(--soft);margin:.3rem 0} +.hero code{font:.85em var(--mono);background:var(--hair2);padding:.06em .35em;border-radius:4px} +.rowlist{list-style:none;margin:.4rem 0;padding:0} +.rowlist li{display:flex;align-items:baseline;gap:.7rem;padding:.5rem .6rem;border-radius:8px; + border:1px solid transparent} +.rowlist li:hover,.rowlist li.cursor{background:var(--iris-wash);border-color:var(--iris-line)} +.rowlist .nm{font-weight:600;cursor:pointer} +.rowlist .mt{font:.72rem var(--mono);color:var(--muted)} +.verdictchip{font:600 .66rem var(--mono);letter-spacing:.05em;padding:.25em .55em; + border-radius:999px;border:1px solid} +.verdictchip.UNVERIFIABLE{color:var(--warn);border-color:rgba(138,90,18,.4)} +.verdictchip.MATCH{color:var(--ok);border-color:rgba(31,107,69,.4)} +.warnrow{font:.78rem var(--mono);color:var(--warn);padding:.2rem 0} +.cycchip{display:inline-block;font:600 .72rem var(--mono);color:var(--warn); + border:1px solid rgba(138,90,18,.4);border-radius:6px;padding:.25em .6em;margin:.15rem .25rem .15rem 0} + +.palette{position:fixed;inset:0;z-index:var(--z-palette);display:none; + background:rgba(11,12,14,.28);backdrop-filter:blur(2px)} +.palette.on{display:block} +.pbox{max-width:620px;margin:9vh auto 0;background:var(--panel);border:1px solid var(--hair); + border-radius:12px;box-shadow:0 24px 60px -24px rgba(11,12,14,.5);overflow:hidden} +.pbox input{width:100%;border:none;outline:none;background:transparent;color:var(--ink); + font:1rem var(--body);padding:.9rem 1.1rem;border-bottom:1px solid var(--hair2)} +.presults{max-height:46vh;overflow:auto;padding:.35rem} +.pr{display:flex;align-items:baseline;gap:.7rem;padding:.5rem .75rem;border-radius:8px;cursor:pointer} +.pr.sel{background:var(--iris-wash)} +.pr .k{font:600 .62rem var(--mono);letter-spacing:.06em;text-transform:uppercase; + color:var(--muted);min-width:3.6rem} +.pr .t{font-weight:600} +.pr .s{font:.72rem var(--mono);color:var(--muted);margin-left:auto;white-space:nowrap} +.phint{font:.68rem var(--mono);color:var(--muted);padding:.5rem .9rem;border-top:1px solid var(--hair2); + display:flex;gap:1rem;flex-wrap:wrap} +.phint kbd{border:1px solid var(--hair);border-radius:4px;padding:.05em .35em;background:var(--hair2)} + +.toast{position:fixed;bottom:1.2rem;left:50%;transform:translate(-50%,.6rem);opacity:0; + z-index:var(--z-toast);background:var(--ink);color:var(--bg);font:600 .78rem var(--mono); + border-radius:8px;padding:.6em 1em;transition:opacity .2s var(--e),transform .2s var(--e); + pointer-events:none;max-width:90vw;word-break:break-all} +.toast.on{opacity:1;transform:translate(-50%,0)} + +.basketbar{border:1px solid var(--iris-line);background:var(--iris-wash);border-radius:10px; + padding:.6rem .8rem;margin:.7rem 0} +.basketbar .bl{font:600 .7rem var(--mono);letter-spacing:.06em;text-transform:uppercase;color:var(--iris)} +.basketbar .items{display:flex;gap:.35rem;flex-wrap:wrap;margin:.4rem 0} +.bitem{font:600 .72rem var(--mono);background:var(--panel);border:1px solid var(--iris-line); + border-radius:6px;padding:.25em .55em;cursor:pointer} +.bitem:hover{text-decoration:line-through} +.basketbar .sum{font:.76rem var(--mono);color:var(--soft)} +.basketbar .sum b{font-variant-numeric:tabular-nums} +.cta{font:600 .74rem var(--mono);background:var(--iris);color:#fff;border-radius:7px; + padding:.45em .8em;margin-top:.35rem} +.cta:focus-visible{outline:2px solid var(--ink);outline-offset:2px} + +/* lens view reuses the stack look */ +.lensrail{display:flex;align-items:center;gap:1rem;margin:.6rem 0 .9rem;flex-wrap:wrap} +.lensrail label{font:600 .74rem var(--mono);color:var(--muted);white-space:nowrap} +.lensrail input[type=range]{flex:1;min-width:12rem;accent-color:var(--iris)} +.lensrail .big{font:600 1.25rem var(--mono);color:var(--iris);font-variant-numeric:tabular-nums} +.item{display:grid;grid-template-columns:1.7rem 1fr auto;gap:.15rem .8rem;padding:.5rem .6rem; + position:relative;border-radius:8px;transition:background .22s var(--e),opacity .22s var(--e)} +.item::before{content:"";position:absolute;left:0;top:6px;bottom:6px;width:3px;border-radius:2px; + background:var(--iris);transform:scaleY(0);transition:transform .22s var(--e)} +.item.in::before{transform:scaleY(1)} +.item.in{background:var(--iris-wash)} +.item.out{opacity:.6} +.item .rank{font:600 .7rem var(--mono);color:var(--muted);text-align:right;font-variant-numeric:tabular-nums} +.item .nm{font-weight:600;cursor:pointer} +.item .cost{font:600 .78rem var(--mono);text-align:right;font-variant-numeric:tabular-nums} +.item .code{color:var(--warn);font-weight:600;font-size:.74rem} +.frontier{height:0;border-top:1.5px dashed var(--iris-line);margin:.1rem .3rem;position:relative; + transition:all .25s var(--e)} +.frontier::after{content:"frontier";position:absolute;right:0;top:-.62rem;font:600 .6rem var(--mono); + color:var(--iris);background:var(--panel);border:1px solid var(--iris-line); + border-radius:999px;padding:.15em .5em} + +footer.receipt{padding:.5rem 1rem;border-top:1px solid var(--hair); + font:.68rem var(--mono);color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +@media(max-width:900px){.main{grid-template-columns:1fr}aside{display:none}} +@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}} +""" diff --git a/client-plugin/server/src/index_graph/viz/workbench_html.py b/client-plugin/server/src/index_graph/viz/workbench_html.py new file mode 100644 index 0000000..4d34324 --- /dev/null +++ b/client-plugin/server/src/index_graph/viz/workbench_html.py @@ -0,0 +1,225 @@ +"""Assemble the self-contained workbench document. + +One page, five views over one sealed pack: Overview (the workspace at a +glance + the context basket), Map (the interactive two-layer atlas with a +live context-lens overlay), Docs (the derived knowledge layer), Context Lens +(the budget frontier), Health (cycles, warnings, salience audit, freshness +fingerprints). Command palette on Ctrl/Cmd-K, full state in the URL hash, +zero runtime dependencies, receipt printed in the footer. +""" +from __future__ import annotations + +import html +import json + +from .workbench_assets import WB_CSS +from .workbench_js import WB_JS_CORE +from .workbench_js2 import WB_JS_UX + +_MAP_EXTRA_CSS = ( + "#map-stage .node.lens-out,#map-stage .docnode.lens-out{opacity:.14}" + "#map-stage .node.sel rect{stroke:#4636e8;stroke-width:2.5}" + "#map-stage .docnode.sel rect{stroke:#4636e8;stroke-width:2}" +) + + +def _stat(n, label, cls="") -> str: + return (f'
        {n}
        ' + f'
        {label}
        ') + + +def _overview(wb: dict) -> str: + s = wb["summary"] + roles = " · ".join(f"{k} {v}" for k, v in s["role_counts"].items()) + tops = ", ".join(s["top_salience"][:5]) + return f"""
        +

        {s['repos']} repos, {s['docs']} docs, one verified map.

        +

        Everything below is derived from file:line evidence at {html.escape(wb['root'])} +and sealed under receipt {html.escape(wb['receipt_sha256'][:16])}…. +Press Ctrl+K for anything; pin repos into the context basket and the +exact index context-envelope command is written for you.

        +
        +{_stat(s['repos'], 'repos')}{_stat(s['docs'], 'docs')} +{_stat(s['relations'], 'dependency edges')}{_stat(s['knowledge_edges'], 'knowledge edges')} +{_stat(s['cycles'], 'cycles', 'iris' if s['cycles'] else '')} +{_stat(s['warnings'], 'warnings', 'iris' if s['warnings'] else '')} +
        +
        roles: {html.escape(roles)}
        +
        highest-salience: {html.escape(tops)}
        +
        +

        all repos

        +
          """ + "".join( + f'
        • {html.escape(r["name"])}' + f'in {r["in_degree"]} · out {r["out_degree"]}' + f'{" · " + html.escape(", ".join(r["roles"])) if r["roles"] else ""}
        • ' + for r in wb["repos"]) + "
        " + + +def _docs_view(wb: dict) -> str: + rows = "".join( + f'
      • ' + f'{html.escape(d["title"])}{html.escape(d["id"])}
      • ' + for d in wb["docs"]) + dm = wb["doc_meta"] + budget_line = "" + if dm["bodies_embedded"] < dm["total"]: + budget_line = (f' Page-weight budget: {dm["bodies_embedded"]} of ' + f'{dm["total"]} bodies embedded (most-connected first); ' + f'the list and search below cover all of them.') + return (f'

        The knowledge layer

        ' + f'

        {len(wb["docs"])} docs discovered and linked to the map by ' + f'describes / links-to / mentions edges — derived, not hand-filed. ' + f'Select one to read it with backlinks in the panel.{budget_line}

        ' + f'
          {rows or "
        • no docs discovered
        • "}
        ') + + +def _lens_view(wb: dict) -> str: + order = wb["lens"]["replay"]["order"] + total = sum(o["cost"] for o in order) + wb["lens"]["replay"]["base_tokens"] + built = wb["lens"]["budget"]["token_budget"] + return f"""

        What fits in the context budget +{wb['lens']['verdict']}

        +

        Repos in the exact rank the envelope fills them. Drag the budget: the frontier slides and +rows cross between retained and omitted, replaying the same greedy rule +index context-envelope runs — what you see is what an agent gets. The Map view +can overlay this frontier onto the graph.

        +
        + + +{built} + +0 used incl. base {wb['lens']['replay']['base_tokens']} +
        +
        """ + + +def _health(wb: dict) -> str: + f = wb["freshness"] + cycles = "".join(f'{html.escape(" → ".join(c + [c[0]]))}' + for c in wb["cycles"]) or 'none — the graph is acyclic' + warns = "".join(f'
        {html.escape(w)}
        ' + for w in (wb["warnings"] + wb["knowledge_warnings"])[:40]) \ + or '
        none
        ' + audit = "".join( + f'
        [{html.escape(a.get("kind", ""))}] {html.escape(a.get("node", ""))}: ' + f'{html.escape(a.get("note", ""))}
        ' + for a in wb["salience_audit"]) or '
        no salience-faithfulness warnings
        ' + fps = "".join(f'
      • {html.escape(n)}' + f'{html.escape(h)}
      • ' + for n, h in sorted(f["repos"].items())) + return f"""

        Structural health & freshness

        +

        What one pass can honestly verify: cycles, warnings, and the salience audit are structural +facts of the current graph. The fingerprints below are the baseline a later run diffs +against — a FRESH/STALE verdict needs a prior snapshot, so none is claimed here.

        +

        re-check: {html.escape(f['recheck'])}

        +

        dependency cycles

        {cycles}
        +

        warnings

        {warns} +

        salience audit

        {audit} +

        workspace fingerprint

        +
        root {html.escape(f['root_sha256'])}
        +
          {fps}
        """ + + +def _spine(wb: dict) -> str: + sp = wb["spine"] + if not sp["tools"]: + return (f'

        The operator spine

        ' + f'

        No flagship envelopes were supplied to this build, so nothing is ' + f'claimed about peers. {html.escape(sp["capture"])}.

        ') + ring = "".join( + f'
        {html.escape(e["from"])} ' + f'→ {html.escape(e["to"])}' + f'{html.escape(e["action"])}' + f'
        {html.escape(e["reason"])}
        ' + for e in sp["ring"]) or '
        no next_actions declared
        ' + tools = "".join( + f'
        {html.escape(t["tool"])}' + f'v{html.escape(t["version"])} · {html.escape(t["command"])}' + f'' + f'{html.escape(t["status"])}' + + "".join(f'
        {html.escape(c["name"])} — {html.escape(c["status"])}
        ' + for c in t["checks"]) + "
        " + for t in sp["tools"]) + peers = "".join( + f'
        {html.escape(s["peer"])}' + f'{html.escape(s["surface"])} · {s["entries"]} entries' + f'
        inspect: {html.escape(s["inspect"])}
        ' + for s in sp["peer_surfaces"]) or '
        none detected under .telos/
        ' + skipped = "".join( + f'
        {html.escape(s["file"])}: {html.escape(s["reason"])}
        ' + for s in sp["skipped"]) + return (f'

        The operator spine

        ' + f'

        The flagships are aware of each other by protocol: every doctor/status ' + f'envelope declares next_actions handing off to a peer. This view ' + f'renders the captured envelopes and the awareness ring they form — receipts ' + f'from the tools themselves, not this page’s opinion.

        ' + f'

        awareness ring (who hands off to whom)

        {ring}' + f'

        captured envelopes

        {tools}' + f'

        peer surfaces on disk

        {peers}' + + (f'

        skipped inputs (fail closed)

        {skipped}' if skipped else "")) + + +def render_workbench_html(wb: dict) -> str: + svg = wb["svg"] + data = {k: v for k, v in wb.items() if k != "svg"} + blob = json.dumps(data, sort_keys=True, separators=(",", ":")).replace("<", "\\u003c") + root = html.escape(str(wb["root"])) + return f""" + +index workbench — {root} + +
        +
        +index · workbench + + + +
        +
        +
        +
        {_overview(wb)}
        +
        +
        + + + +
        +
        +in budget (overlay) +click: inspectdouble-click: neighborhood +wheel: zoom +map draws {wb['doc_meta']['map_docs']} of {wb['doc_meta']['total']} docs · internal deps only ({wb['summary']['external_relations']} external edges counted) +
        +{svg}
        +
        {_docs_view(wb)}
        +
        {_lens_view(wb)}
        +
        {_health(wb)}
        +
        {_spine(wb)}
        +
        + +
        +
        + +
        +
        receipt sha256 {html.escape(wb['receipt_sha256'])} · root {root} · +re-derive: index workbench --root . --out workbench.html
        + +""" diff --git a/client-plugin/server/src/index_graph/viz/workbench_js.py b/client-plugin/server/src/index_graph/viz/workbench_js.py new file mode 100644 index 0000000..107b49c --- /dev/null +++ b/client-plugin/server/src/index_graph/viz/workbench_js.py @@ -0,0 +1,163 @@ +"""Workbench JS part 1: state, routing, detail panel, basket, lens replay. + +Everything renders from the sealed DATA pack; every visual state is a replay +of real numbers (lens costs, evidence lines, fingerprints). URL hash carries +the full UI state (mode/selection/budget/basket) so any view is deep-linkable +and restorable offline. +""" +from __future__ import annotations + +WB_JS_CORE = r""" +const D=DATA,$=s=>document.querySelector(s),$$=s=>[...document.querySelectorAll(s)]; +const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const REPO={};D.repos.forEach(r=>REPO[r.name]=r); +const DOC={};D.docs.forEach(d=>DOC[d.id]=d); +const ORDER=D.lens.replay.order,BASE=D.lens.replay.base_tokens; +const COST={};ORDER.forEach(o=>COST[o.name]=o.cost); +// backlinks are a projection of knowledge_edges — rebuilt here instead of +// shipped twice (a 6MB saving on large workspaces) +const BL={};(D.knowledge_edges||[]).forEach(e=>{ + (BL[e.to]=BL[e.to]||[]).push({from:e.from,type:e.type});}); +const S={mode:'overview',sel:null,budget:D.lens.budget.token_budget,basket:[],trail:[]}; + +/* ---------- hash state (deep-linkable, offline) ---------- */ +function writeHash(){ + const h='#m='+S.mode+(S.sel?'&s='+encodeURIComponent(S.sel.kind+':'+S.sel.id):'') + +'&b='+S.budget+(S.basket.length?'&k='+S.basket.map(encodeURIComponent).join(','):''); + if(location.hash!==h)history.replaceState(null,'',h); +} +function readHash(){ + const q=new URLSearchParams(location.hash.slice(1).replace(/&/g,'&')); + const m=q.get('m');if(m&&$('#mode-'+m))S.mode=m; + const b=parseInt(q.get('b'),10);if(b>0)S.budget=b; + const k=q.get('k');if(k)S.basket=k.split(',').map(decodeURIComponent).filter(n=>REPO[n]); + const s=q.get('s');if(s){const i=s.indexOf(':');const kind=s.slice(0,i),id=decodeURIComponent(s.slice(i+1)); + if((kind==='repo'&&REPO[id])||(kind==='doc'&&DOC[id]))S.sel={kind,id};} +} + +/* ---------- toast ---------- */ +let toastT=null; +function toast(msg){const t=$('#toast');t.textContent=msg;t.classList.add('on'); + clearTimeout(toastT);toastT=setTimeout(()=>t.classList.remove('on'),2600);} +function copy(text,label){ + (navigator.clipboard?navigator.clipboard.writeText(text):Promise.reject()) + .then(()=>toast('copied: '+label)).catch(()=>toast(text)); +} + +/* ---------- modes ---------- */ +function setMode(m){S.mode=m; + $$('.mode').forEach(b=>b.setAttribute('aria-pressed',String(b.id==='mode-'+m))); + $$('.view').forEach(v=>v.classList.toggle('on',v.id==='view-'+m)); + if(m==='map')fitMap(); + writeHash(); +} + +/* ---------- selection + detail panel ---------- */ +function pushTrail(sel){const last=S.trail[S.trail.length-1]; + if(!last||last.id!==sel.id)S.trail.push(sel); + $('#crumbs').innerHTML=S.trail.slice(-6).map((n,i)=>`${esc(n.id.split('/').pop())}`).join(' › '); + $$('#crumbs a').forEach(a=>a.onclick=()=>{const n=S.trail[+a.dataset.i];if(n)select(n.kind,n.id);}); +} +function select(kind,id){S.sel={kind,id};pushTrail(S.sel); + if(kind==='repo')detailRepo(id);else detailDoc(id); + highlightMap();writeHash(); +} +function evLine(e){ + return `
        ${esc(e.kind)} · ${esc(e.file)}${e.line?':'+e.line:''}
        `; +} +function detailRepo(name){const r=REPO[name];if(!r)return; + const inB=S.basket.includes(name); + const deps=(r.depends_on||[]).map(d=>`
        ${esc(d.to)}`+ + `${esc(d.confidence)}${d.in_cycle?'cycle':''}`+ + (d.evidence||[]).map(evLine).join('')+`
        `).join('')||'
        none
        '; + const docs=(r.documented_by||[]).map(id=>``).join('')||'
        none
        '; + const back=(BL[name]||[]).map(b=>``).join('')||'
        none
        '; + $('#detail').innerHTML=`

        ${esc(name)} repo

        `+ + `
        roles ${esc((r.roles||[]).join(', ')||'none')}`+ + `in ${r.in_degree}out ${r.out_degree}`+ + `~${COST[name]||'?'} tok
        `+ + (r.description?`

        ${esc(r.description)}

        `:'')+ + `
        `+ + `

        depends on

        ${deps}

        documented by

        ${docs}

        linked from

        ${back}`+ + `

        freshness fingerprint

        ${esc(r.fingerprint||'n/a')}
        `; + $('#pinbtn').onclick=()=>{togglePin(name);detailRepo(name);}; + wireNav(); +} +function detailDoc(id){const d=DOC[id];if(!d)return; + const back=(BL[id]||[]).map(b=>``).join('')||'
        none
        '; + const body=D.doc_html[id]||('body not embedded (page-weight budget: '+ + D.doc_meta.bodies_embedded+' of '+D.doc_meta.total+' bodies). Open the file at '+ + esc(id)+' or rebuild with --max-doc-bodies.'); + $('#detail').innerHTML=`

        ${esc(d.title)} doc

        `+ + `
        ${esc(id)}
        `+ + `

        linked from

        ${back}`+ + `
        ${body}
        `; + wireNav(); +} +function wireNav(){$$('#detail .navlink,#detail .wikilink').forEach(a=>a.onclick=ev=>{ + ev.preventDefault(); + if(a.dataset.atlasTarget){const t=resolveWiki(a.dataset.atlasTarget);if(t)select(t.kind,t.id);return;} + select(a.dataset.kind,a.dataset.id);});} +const norm=s=>String(s).trim().toLowerCase().replace(/_/g,'-').replace(/ /g,'-'); +const WIKI={};D.repos.forEach(r=>{WIKI[norm(r.name)]={kind:'repo',id:r.name};}); +D.docs.forEach(d=>{[d.title,d.id.split('/').pop().replace(/\.[^.]+$/,'')] + .forEach(c=>{if(!(norm(c)in WIKI))WIKI[norm(c)]={kind:'doc',id:d.id};});}); +function resolveWiki(t){return WIKI[norm(t)];} + +/* ---------- context basket ---------- */ +function togglePin(name){const i=S.basket.indexOf(name); + if(i>=0)S.basket.splice(i,1);else S.basket.push(name); + renderBasket();writeHash(); +} +function basketTokens(){return S.basket.reduce((a,n)=>a+(COST[n]||0),BASE);} +function envelopeCmd(){ + return 'index context-envelope --root . --budget '+Math.max(basketTokens(),1) + +(S.basket.length===1?' --focus '+S.basket[0]:'')+' --json'; +} +function renderBasket(){ + $('#basket-n').textContent=S.basket.length; + $('#basketbtn').dataset.n=S.basket.length; + const bar=$('#basketbar'); + if(!S.basket.length){bar.innerHTML='context basket'+ + '
        Pin repos from the detail panel (or press p) to assemble a working set; the exact envelope command is generated for you.
        ';return;} + bar.innerHTML=`context basket`+ + `
        ${S.basket.map(n=>``).join('')}
        `+ + `
        ~${basketTokens()} tokens incl. base ${BASE} · ${S.basket.length} repos
        `+ + ``; + $$('#basketbar .bitem').forEach(b=>b.onclick=()=>{togglePin(b.dataset.n); + if(S.sel&&S.sel.kind==='repo')detailRepo(S.sel.id);}); + $('#copy-env').onclick=()=>copy(envelopeCmd(),'envelope command'); +} + +/* ---------- lens (budget replay — mirrors context/lens.py) ---------- */ +function replay(budget){const keep=new Set();let approx=BASE; + for(const o of ORDER){if(approx+o.cost<=budget||keep.size===0){keep.add(o.name);approx+=o.cost;}} + return{keep,approx};} +function renderLens(){ + const{keep,approx}=replay(S.budget); + let lastIn=-1; + ORDER.forEach((o,i)=>{const el=o._el;if(!el)return; + const inC=keep.has(o.name);el.classList.toggle('in',inC);el.classList.toggle('out',!inC); + if(inC)lastIn=i; + el.querySelector('.cost').innerHTML=inC?('~'+o.cost):'budget_exceeded';}); + const fr=$('#frontier'),stack=$('#lens-stack'); + const anchor=lastIn>=0?ORDER[lastIn]._el:null; + if(anchor&&anchor.nextSibling!==fr)stack.insertBefore(fr,anchor.nextSibling); + $('#lens-used').textContent=approx;$('#lens-bval').textContent=S.budget; + const v=keep.size{const li=document.createElement('div');li.className='item'; + li.innerHTML=`${String(i+1).padStart(2,'0')}`+ + `${esc(o.name)}`+ + `~${o.cost}`; + li.querySelector('.nm').onclick=()=>select('repo',o.name); + stack.appendChild(li);o._el=li;}); + const fr=document.createElement('div');fr.className='frontier';fr.id='frontier';stack.appendChild(fr); + const sl=$('#lens-slider');sl.value=S.budget; + sl.oninput=()=>{S.budget=+sl.value;renderLens();writeHash();}; +} +""" diff --git a/client-plugin/server/src/index_graph/viz/workbench_js2.py b/client-plugin/server/src/index_graph/viz/workbench_js2.py new file mode 100644 index 0000000..54102e9 --- /dev/null +++ b/client-plugin/server/src/index_graph/viz/workbench_js2.py @@ -0,0 +1,126 @@ +"""Workbench JS part 2: map interactions, command palette, keyboard, boot.""" +from __future__ import annotations + +WB_JS_UX = r""" +/* ---------- map (pan/zoom/select/focus + lens overlay) ---------- */ +let view={k:1,tx:0,ty:0}; +function applyView(){const vp=$('#map-stage #viewport'); + if(vp)vp.setAttribute('transform',`translate(${view.tx},${view.ty}) scale(${view.k})`);} +function fitMap(){view={k:1,tx:0,ty:0};applyView();} +function svgPt(svg,cx,cy){const r=svg.getBoundingClientRect(),vb=svg.viewBox.baseVal; + return{x:(cx-r.left)/r.width*vb.width,y:(cy-r.top)/r.height*vb.height};} +function wireMap(){const stage=$('#map-stage'),svg=stage&&stage.querySelector('svg');if(!svg)return; + svg.removeAttribute('width');svg.removeAttribute('height'); + svg.addEventListener('wheel',ev=>{ev.preventDefault(); + const p=svgPt(svg,ev.clientX,ev.clientY),f=ev.deltaY<0?1.12:1/1.12, + nk=Math.min(9,Math.max(.15,view.k*f)); + view.tx=p.x-(p.x-view.tx)*(nk/view.k);view.ty=p.y-(p.y-view.ty)*(nk/view.k);view.k=nk;applyView();},{passive:false}); + let drag=null; + svg.addEventListener('pointerdown',ev=>{drag={x:ev.clientX,y:ev.clientY,tx:view.tx,ty:view.ty}; + stage.classList.add('grabbing');svg.setPointerCapture(ev.pointerId);}); + svg.addEventListener('pointermove',ev=>{if(!drag)return; + const r=svg.getBoundingClientRect(),vb=svg.viewBox.baseVal; + view.tx=drag.tx+(ev.clientX-drag.x)*vb.width/r.width; + view.ty=drag.ty+(ev.clientY-drag.y)*vb.height/r.height;applyView();}); + svg.addEventListener('pointerup',()=>{drag=null;stage.classList.remove('grabbing');}); + $$('#map-stage .node').forEach(g=>{g.style.cursor='pointer'; + g.addEventListener('click',()=>select('repo',g.dataset.name)); + g.addEventListener('dblclick',()=>focusHood('repo',g.dataset.name));}); + $$('#map-stage .docnode').forEach(g=>{g.style.cursor='pointer'; + g.addEventListener('click',()=>select('doc',g.dataset.doc));}); + $('#zoom-fit').onclick=fitMap; + $('#focus-clear').onclick=()=>{$$('#map-stage .dim').forEach(e=>e.classList.remove('dim'));}; + $('#toggle-lens-overlay').onclick=e=>{const on=e.currentTarget.getAttribute('aria-pressed')==='true'; + e.currentTarget.setAttribute('aria-pressed',String(!on));highlightMap();}; +} +function focusHood(kind,id){const keep=new Set([kind+':'+id]); + D.repos.forEach(r=>{(r.depends_on||[]).forEach(d=>{ + if(r.name===id)keep.add('repo:'+d.to); + if(d.to===id)keep.add('repo:'+r.name);});}); + (D.knowledge_edges||[]).forEach(e=>{ + if(kind==='doc'&&e.from===id)keep.add(e.to_kind+':'+e.to); + if(e.to===id&&e.to_kind===kind)keep.add('doc:'+e.from);}); + $$('#map-stage .node').forEach(g=>g.classList.toggle('dim',!keep.has('repo:'+g.dataset.name))); + $$('#map-stage .docnode').forEach(g=>g.classList.toggle('dim',!keep.has('doc:'+g.dataset.doc))); + $$('#map-stage .edge').forEach(p=>p.classList.toggle('dim', + !(keep.has('repo:'+p.dataset.from)&&keep.has('repo:'+p.dataset.to)))); + $$('#map-stage .kedge').forEach(l=>l.classList.toggle('dim', + !(keep.has('doc:'+l.dataset.from)&&(keep.has('repo:'+l.dataset.to)||keep.has('doc:'+l.dataset.to))))); +} +function highlightMap(keepSet){ + // lens overlay: when ON, repos outside the current budget replay get dimmed + const btn=$('#toggle-lens-overlay'); + const on=btn&&btn.getAttribute('aria-pressed')==='true'; + const keep=keepSet||replay(S.budget).keep; + $$('#map-stage .node').forEach(g=>{ + g.classList.toggle('lens-out',on&&!keep.has(g.dataset.name)); + g.classList.toggle('sel',!!S.sel&&S.sel.kind==='repo'&&S.sel.id===g.dataset.name);}); + $$('#map-stage .docnode').forEach(g=> + g.classList.toggle('sel',!!S.sel&&S.sel.kind==='doc'&&S.sel.id===g.dataset.doc)); +} + +/* ---------- command palette (ctrl/cmd-K) ---------- */ +const ACTIONS=[ + {k:'mode',t:'Go to Overview',run:()=>setMode('overview')}, + {k:'mode',t:'Go to Map',run:()=>setMode('map')}, + {k:'mode',t:'Go to Docs',run:()=>setMode('docs')}, + {k:'mode',t:'Go to Context Lens',run:()=>setMode('lens')}, + {k:'mode',t:'Go to Health',run:()=>setMode('health')}, + {k:'mode',t:'Go to Spine (flagship interop)',run:()=>setMode('spine')}, + {k:'copy',t:'Copy envelope command (basket)',run:()=>copy(envelopeCmd(),'envelope command')}, + {k:'copy',t:'Copy re-check: freshness',run:()=>copy(D.freshness.recheck,'freshness re-check')}, + {k:'copy',t:'Copy receipt sha256',run:()=>copy(D.receipt_sha256,'receipt sha256')}, +]; +let pIdx=0,pHits=[]; +function pOpen(){$('#palette').classList.add('on');const i=$('#pinput');i.value='';pQuery('');i.focus();} +function pClose(){$('#palette').classList.remove('on');} +function fuzzy(q,s){q=q.toLowerCase();s=s.toLowerCase();let i=0; + for(const c of s){if(c===q[i])i++;if(i===q.length)return true;}return q.length===0;} +function pQuery(q){ + const hits=[]; + ACTIONS.forEach(a=>{if(fuzzy(q,a.t))hits.push({k:a.k,t:a.t,s:'action',run:a.run});}); + D.repos.forEach(r=>{if(fuzzy(q,r.name))hits.push({k:'repo',t:r.name, + s:(r.roles||[]).join('/')||'repo',run:()=>{select('repo',r.name);}});}); + D.docs.forEach(d=>{if(fuzzy(q,d.title)||fuzzy(q,d.id))hits.push({k:'doc',t:d.title, + s:d.id,run:()=>{setMode('docs');select('doc',d.id);}});}); + pHits=hits.slice(0,40);pIdx=0; + $('#presults').innerHTML=pHits.map((h,i)=> + `
        ${esc(h.k)}`+ + `${esc(h.t)}${esc(h.s)}
        `).join('') + ||'
        no matches
        '; + $$('#presults .pr').forEach(el=>el.onclick=()=>{const h=pHits[+el.dataset.i];if(h){pClose();h.run();}}); +} +function pMove(d){if(!pHits.length)return;pIdx=(pIdx+d+pHits.length)%pHits.length; + $$('#presults .pr').forEach((el,i)=>el.classList.toggle('sel',i===pIdx)); + const el=$$('#presults .pr')[pIdx];if(el)el.scrollIntoView({block:'nearest'});} + +/* ---------- keyboard ---------- */ +document.addEventListener('keydown',ev=>{ + const pal=$('#palette').classList.contains('on'); + if((ev.ctrlKey||ev.metaKey)&&ev.key.toLowerCase()==='k'){ev.preventDefault();pal?pClose():pOpen();return;} + if(pal){ + if(ev.key==='Escape'){pClose();} + else if(ev.key==='ArrowDown'){ev.preventDefault();pMove(1);} + else if(ev.key==='ArrowUp'){ev.preventDefault();pMove(-1);} + else if(ev.key==='Enter'){const h=pHits[pIdx];if(h){pClose();h.run();}} + return;} + if(ev.target.tagName==='INPUT')return; + const m={'1':'overview','2':'map','3':'docs','4':'lens','5':'health','6':'spine'}[ev.key]; + if(m){setMode(m);return;} + if(ev.key==='/'){ev.preventDefault();pOpen();return;} + if(ev.key==='p'&&S.sel&&S.sel.kind==='repo'){togglePin(S.sel.id);detailRepo(S.sel.id);return;} + if(ev.key==='Escape'){$$('#map-stage .dim').forEach(e=>e.classList.remove('dim'));} +}); +$('#pinput')&&($('#pinput').oninput=e=>pQuery(e.target.value)); +$('#palette').addEventListener('click',ev=>{if(ev.target.id==='palette')pClose();}); + +/* ---------- boot ---------- */ +readHash(); +$$('.mode').forEach(b=>b.onclick=()=>setMode(b.id.slice(5))); +$('#searchbtn').onclick=pOpen; +$('#basketbtn').onclick=()=>{setMode('overview'); + $('#basketbar').scrollIntoView({block:'center'});}; +buildLens();renderLens();renderBasket();wireMap(); +setMode(S.mode); +if(S.sel)select(S.sel.kind,S.sel.id); +""" diff --git a/client-plugin/server/src/index_graph/wiki/__init__.py b/client-plugin/server/src/index_graph/wiki/__init__.py new file mode 100644 index 0000000..9372b89 --- /dev/null +++ b/client-plugin/server/src/index_graph/wiki/__init__.py @@ -0,0 +1,19 @@ +"""The verified wiki: single-repo pages derived from the real module graph, +joined with authored docs, sealed with per-page hashes, pinned to a commit, +and re-checkable with `index wiki --verify` (MATCH/DRIFT/UNVERIFIABLE).""" +from __future__ import annotations + +from .pack import CLUSTER_THRESHOLD, build_wiki_pack +from .html import render_wiki_html +from .seal import (WIKI_SCHEMA, extract_embedded_pack, load_artifact, + run_verify, verify_wiki) +from .cli import CloneError, add_wiki_parser, clone_repo, cmd_wiki +from . import serve +from .serve import make_server, parse_route, serve_forever + +__all__ = [ + "WIKI_SCHEMA", "CLUSTER_THRESHOLD", "build_wiki_pack", "render_wiki_html", + "verify_wiki", "run_verify", "load_artifact", "extract_embedded_pack", + "add_wiki_parser", "cmd_wiki", "CloneError", "clone_repo", + "serve", "make_server", "parse_route", "serve_forever", +] diff --git a/client-plugin/server/src/index_graph/wiki/cli.py b/client-plugin/server/src/index_graph/wiki/cli.py new file mode 100644 index 0000000..2bd28f1 --- /dev/null +++ b/client-plugin/server/src/index_graph/wiki/cli.py @@ -0,0 +1,121 @@ +"""CLI face for `index wiki`: generate the sealed artifact, or verify one. + +A `source` positional accepts a git URL or a local path: a URL is shallow-cloned +to a temp dir, run, and cleaned up, so `index wiki https://github.com/org/repo` +gives the paste-a-repo experience without a server. A local path (or --root) +reads in place. The clone is a normal git clone: nothing is fetched that a plain +`git clone --depth 1` would not fetch, and the temp dir is always removed. +""" +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +_URL_SCHEMES = ("http://", "https://", "git://", "ssh://", "file://") + + +class CloneError(RuntimeError): + """A shallow clone could not be completed. Carries a plain reason string so + callers (the CLI, the on-demand server) can surface it without a traceback.""" + + +def clone_repo(source: str, dest: Path) -> None: + """Shallow-clone `source` into `dest`. Raises CloneError with a plain message + on failure. Shared by `index wiki` and `index serve` so there is one clone + code path, not two.""" + try: + subprocess.run(["git", "clone", "--depth", "1", source, str(dest)], + check=True, capture_output=True, text=True) + except (subprocess.CalledProcessError, OSError) as exc: + raise CloneError(f"could not clone {source}: {exc}") from exc + + +def add_wiki_parser(sub) -> None: + w = sub.add_parser( + "wiki", + help="Single-repo verified wiki: pages derived from the module graph, " + "sealed and commit-pinned; --verify re-checks a sealed artifact.") + w.add_argument("source", nargs="?", default=None, + help="a git URL (shallow-cloned then removed) or a local path " + "to derive the wiki from; overrides --root when given") + w.add_argument("--root", type=Path, default=Path.cwd(), + help="the single repo to derive the wiki from") + w.add_argument("--out", default=None, help="write the artifact to this path") + w.add_argument("--format", choices=["html", "json"], default="html") + w.add_argument("--verify", type=Path, default=None, + help="verify a sealed wiki artifact against the current tree " + "(MATCH/DRIFT/UNVERIFIABLE, exit 0/1/2)") + w.add_argument("--json", action="store_true", + help="with --verify, emit the verification report as JSON") + + +def _emit_report(report: dict, as_json: bool) -> int: + from .seal import VERDICT_EXIT + if as_json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + print(f"verdict={report['verdict']} pages={report['pages_checked']} " + f"edges={report['edges_checked']}") + for finding in report["findings"]: + print(f" [{finding['rule']}] {finding['detail']}") + return VERDICT_EXIT[report["verdict"]] + + +def _is_url(source: str) -> bool: + return source.startswith(_URL_SCHEMES) or source.startswith("git@") + + +def _clone(source: str) -> Path: + dest = Path(tempfile.mkdtemp(prefix="index-wiki-")) + try: + clone_repo(source, dest) + except CloneError as exc: + shutil.rmtree(dest, ignore_errors=True) + raise SystemExit(str(exc)) from exc + return dest + + +def _resolve_source(source: str) -> tuple[Path, Path | None]: + """Return (root, tempdir-to-clean). A URL is cloned; a local path is read in + place; anything else is rejected rather than silently treated as a path.""" + if _is_url(source): + clone = _clone(source) + return clone, clone + path = Path(source) + if path.is_dir(): + return path.resolve(), None + raise SystemExit(f"source is not a git URL or an existing directory: {source}") + + +def cmd_wiki(args) -> int: + if args.verify is not None: + from .seal import run_verify + return _emit_report(run_verify(args.verify, args.root.resolve()), args.json) + root, tmp = (_resolve_source(args.source) if args.source + else (args.root.resolve(), None)) + try: + if not root.is_dir(): + raise SystemExit(f"root not found: {root}") + return _generate(root, args) + finally: + if tmp is not None: + shutil.rmtree(tmp, ignore_errors=True) + + +def _generate(root: Path, args) -> int: + from .pack import build_wiki_pack + pack = build_wiki_pack(root) + if args.format == "json": + text = json.dumps(pack, indent=2, sort_keys=True) + else: + from .html import render_wiki_html + text = render_wiki_html(pack) + if args.out: + Path(args.out).write_text(text, encoding="utf-8") + print(f"wrote {args.out}") + else: + print(text) + return 0 diff --git a/client-plugin/server/src/index_graph/wiki/html.py b/client-plugin/server/src/index_graph/wiki/html.py new file mode 100644 index 0000000..f6adfd3 --- /dev/null +++ b/client-plugin/server/src/index_graph/wiki/html.py @@ -0,0 +1,191 @@ +"""Render the wiki pack to one self-contained HTML file with client-side nav. + +Follows the atlas posture: no external scripts, styles, or fonts; every +untrusted string is escaped server-side; doc markdown arrives pre-rendered +by the escaping-safe renderer; the sealed pack is embedded as a JSON data +island (with ``<`` escaped) so ``index wiki --verify`` can read it back. +""" +from __future__ import annotations + +import json +from html import escape + +from ..viz.theme import css_variables +from .seal import EMBED_CLOSE, EMBED_OPEN + +_CSS = ( + "*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);" + "font-family:var(--font-body)}" + "header{padding:.7rem 1rem;border-bottom:1px solid var(--hairline);" + "font-family:var(--font-mono);font-size:.85rem}" + "main{display:grid;grid-template-columns:260px 1fr;min-height:100vh}" + "nav{border-right:1px solid var(--hairline);padding:1rem;font-family:var(--font-mono);" + "font-size:.8rem;overflow:auto}" + "nav a{display:block;color:var(--ink);text-decoration:none;padding:.15rem .3rem;" + "border-radius:4px;word-break:break-all}" + "nav a.active{background:var(--accent);color:var(--bg)}" + "#pages{padding:1rem 1.5rem;overflow:auto}" + "section.page footer{margin-top:1.2rem;padding-top:.5rem;border-top:1px solid " + "var(--hairline);font-family:var(--font-mono);font-size:.72rem;color:var(--muted)}" + "table{border-collapse:collapse}th,td{border:1px solid var(--hairline);" + "padding:.2em .6em;text-align:left}" + ".ev{font-family:var(--font-mono);font-size:.78em;color:var(--muted)}" + ".label{font-family:var(--font-mono);font-size:.78rem;color:var(--muted)}" + "article{border-top:1px solid var(--hairline);margin-top:1rem;padding-top:.5rem}" + ".md pre{background:rgba(255,255,255,.55);border:1px solid var(--hairline);" + "padding:.5em;overflow:auto}" + "svg{max-width:100%;height:auto}" + "@media(max-width:820px){main{grid-template-columns:1fr}nav{border-right:none}}" +) + +_JS = ( + "const links=[...document.querySelectorAll('[data-target]')];" + "function show(t){document.querySelectorAll('.page').forEach(" + "s=>{s.hidden=(s.id!==t);});" + "links.forEach(a=>{a.classList.toggle('active',a.dataset.target===t);});}" + "links.forEach(a=>a.addEventListener('click'," + "ev=>{ev.preventDefault();show(a.dataset.target);}));" + "show('page-0');" +) + + +def _loc(e: dict) -> str: + line = e.get("line") + return f"{e['file']}:{line}" if line else str(e["file"]) + + +def _footer(page: dict) -> str: + n = page["boundary"]["evidence_count"] + return ("
        structure derived from the dependency graph; no generated " + f"prose; evidence shown: {n} file:line reference(s)
        ") + + +def _overview_section(p: dict) -> str: + rows = [("repo", p["repo"]), ("commit", p["commit"]), + ("ecosystems", ", ".join(p["ecosystems"]) or "none"), + ("modules", str(p["module_count"])), + ("internal edges", str(p["internal_edge_count"])), + ("cycles", str(p["cycle_count"])), + ("entry points (no internal importer)", + ", ".join(p["entry_points"]) or "none")] + table = "".join(f"{escape(k)}{escape(v)}" + for k, v in rows) + inventory = "".join(f"
      • {escape(d)}
      • " + for d in p["doc_paths"]) or "
      • none
      • " + cov = p["coverage"] + coverage = ("complete" if cov["complete"] else + f"{len(cov['parse_errors'])} unparsed file(s), " + f"{len(cov['dynamic_imports'])} dynamic import(s) not followed") + return (f"

        {escape(p['title'])}

        {table}
        " + f"

        doc inventory ({p['doc_count']})

          {inventory}
        " + f"

        graph coverage: {escape(coverage)}

        ") + + +def _edge_list(items: list[dict], key: str) -> str: + lis = "".join( + f"
      • {escape(e[key])} " + f"{escape(_loc(e))}
      • " for e in items) + return f"
          {lis}
        " if lis else "

        none

        " + + +def _module_section(p: dict) -> str: + cycles = "".join(f"
      • {escape(' -> '.join(c))}
      • " + for c in p["cycles"]) + return (f"

        {escape(p['title'])}

        " + f"

        {escape(p['path'])} ({escape(p['language'])})

        " + f"

        imports

        {_edge_list(p['imports'], 'to')}" + f"

        dependents

        {_edge_list(p['dependents'], 'from')}" + + (f"

        cycle membership

          {cycles}
        " if cycles else "")) + + +def _via_list(groups: list[dict], key: str) -> str: + out = [] + for group in groups: + vias = "".join( + f"
      • {escape(v['from'])} -> {escape(v['to'])} " + f"{escape(_loc(v))}
      • " for v in group["via"]) + out.append(f"
      • {escape(group[key])}
          {vias}
      • ") + return f"
          {''.join(out)}
        " if out else "

        none

        " + + +def _package_section(p: dict) -> str: + mods = "".join(f"
      • {escape(m)}
      • " for m in p["modules"]) + return (f"

        {escape(p['title'])}

        " + f"

        modules ({len(p['modules'])})

          {mods}
        " + f"

        imports

        {_via_list(p['imports'], 'to')}" + f"

        dependents

        {_via_list(p['dependents'], 'from')}") + + +def _symbol_section(p: dict) -> str: + d = p["definition"] + defloc = f"{d['file']}:{d['line']}" + callers = "".join( + f"
      • {escape(e['from_symbol'])} " + f"{escape(_loc(e))}
      • " for e in p["callers"]) + callees = "".join( + f"
      • {escape(e['to_symbol'])} " + f"{escape(_loc(e))}
      • " for e in p["callees"]) + unresolved = "".join( + f"
      • {escape(e['to_name'])} " + f"{escape(_loc(e))} " + f"{escape(e['reason'])}
      • " + for e in p["unresolved_calls"]) + parent = f" in {escape(p['parent'])}" if p.get("parent") else "" + return (f"

        {escape(p['title'])}

        " + f"

        {escape(p['symbol_kind'])} " + f"{escape(p['symbol'])}{parent}, defined at " + f"{escape(defloc)}

        " + f"

        called by ({len(p['callers'])})

        " + + (f"
          {callers}
        " if callers else "

        none

        ") + + f"

        calls ({len(p['callees'])})

        " + + (f"
          {callees}
        " if callees else "

        none

        ") + + (f"

        unresolved references ({len(p['unresolved_calls'])})

        " + f"
          {unresolved}
        " if unresolved else "")) + + +def _architecture_section(p: dict) -> str: + return (f"

        {escape(p['title'])}

        " + f"

        rendered from the real {escape(p['granularity'])} " + f"graph, {p['edge_count']} evidence-backed edge(s); never inferred

        " + f"{p['svg']}" + f"
        mermaid source" + f"
        {escape(p['mermaid'])}
        ") + + +def _docs_section(p: dict) -> str: + articles = "".join( + f"

        {escape(d['title'])} {escape(d['path'])}

        " + f"
        {d['html']}
        " for d in p["docs"]) + return (f"

        {escape(p['title'])}

        " + "

        authored by humans; joined in verbatim and " + "rendered offline, never rewritten

        " + (articles or "

        none

        ")) + + +_SECTIONS = {"overview": _overview_section, "module": _module_section, + "package": _package_section, "architecture": _architecture_section, + "symbol": _symbol_section, "docs": _docs_section} + + +def render_wiki_html(pack: dict) -> str: + nav, sections = [], [] + for i, page in enumerate(pack["pages"]): + target = f"page-{i}" + nav.append(f'{escape(page["title"])}') + body = _SECTIONS[page["kind"]](page) + sections.append(f'
        {body}{_footer(page)}
        ') + blob = json.dumps(pack, sort_keys=True, + separators=(",", ":")).replace("<", "\\u003c") + return ( + "" + '' + '' + f"index | wiki | {escape(pack['repo'])}" + f"" + f"
        index wiki: {escape(pack['repo'])} " + f"pinned to {escape(pack['commit'])}
        " + f'
        ' + f'
        {"".join(sections)}
        ' + f"{EMBED_OPEN}{blob}{EMBED_CLOSE}" + f"" + "" + ) diff --git a/client-plugin/server/src/index_graph/wiki/pack.py b/client-plugin/server/src/index_graph/wiki/pack.py new file mode 100644 index 0000000..b416f22 --- /dev/null +++ b/client-plugin/server/src/index_graph/wiki/pack.py @@ -0,0 +1,253 @@ +"""Derive the verified-wiki pack from the real module graph of one repo. + +Tier 1 by construction: zero-dependency, model-free, deterministic. Every +page is a projection of the internals graph (Python AST-exact, other +languages best-effort per docs/PROTOCOL.md) or of authored markdown joined +in verbatim. No prose is generated, and every edge shown carries the +file:line evidence the graph recorded for it. +""" +from __future__ import annotations + +from pathlib import Path + +from .. import __version__ +from ..internals import InternalGraph, build_internals +from ..knowledge.docs import discover_docs +from ..knowledge.markdown import render_markdown +from ..symbols import SymbolGraph, build_symbol_graph +from ..viz import build_layout, render_mermaid, render_svg +from .seal import WIKI_SCHEMA, build_manifest, head_commit + +# Above this module count the wiki collapses module pages into package pages. +CLUSTER_THRESHOLD = 120 +# Above this symbol count the wiki omits per-symbol pages to avoid bloat; the +# symbol graph is still derived and sealable via `index internals-symbols`. +SYMBOL_PAGE_LIMIT = 800 +_HUB_FAN_IN = 4 + + +def _boundary(evidence_count: int) -> dict: + return {"derived_from": "dependency-graph", "generated_prose": False, + "evidence_count": evidence_count} + + +def _role(fan_in: int, fan_out: int) -> str: + if fan_in == 0 and fan_out > 0: + return "entrypoint" + if fan_in >= _HUB_FAN_IN: + return "hub" + if fan_in == 0 and fan_out == 0: + return "isolated" + if fan_out == 0: + return "leaf" + return "library" + + +def _graph_pack(ids: list[str], languages: dict[str, str], + pair_signals: dict[tuple[str, str], list[dict]], + cycles: list[list[str]]) -> dict: + """Shape a module or package graph like a context pack so the existing + layout/SVG/mermaid renderers draw it unchanged.""" + fan_in: dict[str, int] = {} + fan_out: dict[str, int] = {} + for frm, to in pair_signals: + fan_out[frm] = fan_out.get(frm, 0) + 1 + fan_in[to] = fan_in.get(to, 0) + 1 + roles, salience = {}, {} + for node in ids: + fi, fo = fan_in.get(node, 0), fan_out.get(node, 0) + roles[node] = [_role(fi, fo)] + salience[node] = {"in_degree": fi, "out_degree": fo, "hub": fi >= _HUB_FAN_IN} + cycle_sets = [set(c) for c in cycles] + relations = [] + for (frm, to), signals in sorted(pair_signals.items()): + confidence = "high" if languages.get(frm) == "python" else "moderate" + relations.append({"from": frm, "to": to, "target_name": to, "external": False, + "confidence": confidence, "signals": signals, + "in_cycle": any(frm in c and to in c for c in cycle_sets)}) + return {"repos": [{"name": n} for n in sorted(ids)], "relations": relations, + "roles": roles, "salience": salience, "cycles": [list(c) for c in cycles]} + + +def _module_graph_pack(g: InternalGraph) -> dict: + pairs: dict[tuple[str, str], list[dict]] = {} + for e in g.edges: + pairs.setdefault((e.from_id, e.to_id), []).append( + {"kind": "import", "file": e.evidence_file, + "line": e.evidence_line, "raw": e.raw}) + languages = {m.id: m.language for m in g.modules} + return _graph_pack([m.id for m in g.modules], languages, pairs, + [list(c) for c in g.cycles]) + + +def _package_of(module_id: str) -> str: + return module_id.rsplit("/", 1)[0] if "/" in module_id else "(root)" + + +def _package_graph_pack(g: InternalGraph) -> dict: + pairs: dict[tuple[str, str], list[dict]] = {} + for e in g.edges: + frm, to = _package_of(e.from_id), _package_of(e.to_id) + if frm != to: + pairs.setdefault((frm, to), []).append( + {"kind": "import", "file": e.evidence_file, + "line": e.evidence_line, "raw": e.raw}) + members: dict[str, set[str]] = {} + for m in g.modules: + members.setdefault(_package_of(m.id), set()).add(m.language) + languages = {pkg: ("python" if langs == {"python"} else "mixed") + for pkg, langs in members.items()} + return _graph_pack(sorted(members), languages, pairs, []) + + +def _overview_page(g: InternalGraph, docs: list, commit: str) -> dict: + entry_points = sorted(m.id for m in g.modules + if g.fan_in.get(m.id, 0) == 0 and g.fan_out.get(m.id, 0) > 0) + return {"id": "overview", "kind": "overview", "title": f"{g.repo} overview", + "repo": g.repo, "commit": commit, + "ecosystems": sorted({m.language for m in g.modules}), + "entry_points": entry_points, + "module_count": len(g.modules), "internal_edge_count": len(g.edges), + "cycle_count": len(g.cycles), + "doc_count": len(docs), "doc_paths": [d.rel_path for d in docs], + "coverage": {"complete": g.coverage.complete, + "parse_errors": list(g.coverage.parse_errors), + "dynamic_imports": [{"file": f, "line": ln} + for f, ln in g.coverage.dynamic_imports]}, + "boundary": _boundary(0)} + + +def _module_pages(g: InternalGraph) -> list[dict]: + imports_by: dict[str, list[dict]] = {} + dependents_by: dict[str, list[dict]] = {} + for e in g.edges: + imports_by.setdefault(e.from_id, []).append( + {"to": e.to_id, "file": e.evidence_file, "line": e.evidence_line, "raw": e.raw}) + dependents_by.setdefault(e.to_id, []).append( + {"from": e.from_id, "file": e.evidence_file, "line": e.evidence_line, "raw": e.raw}) + pages = [] + for m in g.modules: + imports = imports_by.get(m.id, []) + dependents = dependents_by.get(m.id, []) + pages.append({"id": f"module/{m.id}", "kind": "module", "title": m.id, + "module": m.id, "path": m.path, "language": m.language, + "imports": imports, "dependents": dependents, + "cycles": [list(c) for c in g.cycles if m.id in c], + "boundary": _boundary(len(imports) + len(dependents))}) + return pages + + +def _package_pages(g: InternalGraph) -> list[dict]: + members: dict[str, list[str]] = {} + for m in g.modules: + members.setdefault(_package_of(m.id), []).append(m.id) + outgoing: dict[str, dict[str, list[dict]]] = {} + incoming: dict[str, dict[str, list[dict]]] = {} + for e in g.edges: + frm, to = _package_of(e.from_id), _package_of(e.to_id) + if frm == to: + continue + via = {"from": e.from_id, "to": e.to_id, + "file": e.evidence_file, "line": e.evidence_line} + outgoing.setdefault(frm, {}).setdefault(to, []).append(via) + incoming.setdefault(to, {}).setdefault(frm, []).append(via) + pages = [] + for pkg in sorted(members): + imports = [{"to": t, "via": v} for t, v in sorted(outgoing.get(pkg, {}).items())] + dependents = [{"from": f, "via": v} for f, v in sorted(incoming.get(pkg, {}).items())] + evidence = (sum(len(i["via"]) for i in imports) + + sum(len(d["via"]) for d in dependents)) + pages.append({"id": f"package/{pkg}", "kind": "package", "title": pkg, + "package": pkg, "modules": sorted(members[pkg]), + "imports": imports, "dependents": dependents, + "boundary": _boundary(evidence)}) + return pages + + +def _architecture_page(pack: dict, granularity: str, edge_count: int) -> dict: + return {"id": "architecture", "kind": "architecture", "title": "Architecture", + "granularity": granularity, + "svg": render_svg(build_layout(pack, include_external=False)), + "mermaid": render_mermaid(pack, include_external=False), + "edge_count": edge_count, "cycles": pack["cycles"], + "boundary": _boundary(edge_count)} + + +def _docs_page(docs: list) -> dict: + entries = [{"path": d.rel_path, "title": d.title, "html": render_markdown(d.body)} + for d in docs] + return {"id": "docs", "kind": "docs", "title": "Docs", + "provenance": "authored-by-humans", "docs": entries, + "boundary": _boundary(0)} + + +def _symbol_boundary(evidence_count: int) -> dict: + return {"derived_from": "symbol-call-graph", "generated_prose": False, + "evidence_count": evidence_count} + + +def _symbol_pages(sg: SymbolGraph) -> list[dict]: + """One page per symbol: definition, resolved callers/callees with evidence, + and any unresolved references surfaced honestly (never a guessed edge). + + Skipped entirely above SYMBOL_PAGE_LIMIT so a huge repo does not bloat the + pack; the symbol graph itself is still derivable and sealable on the CLI. + """ + if len(sg.symbols) > SYMBOL_PAGE_LIMIT: + return [] + callers_by: dict[str, list[dict]] = {} + callees_by: dict[str, list[dict]] = {} + unresolved_by: dict[str, list[dict]] = {} + for c in sg.calls: + if c.to_symbol is not None: + callees_by.setdefault(c.from_symbol, []).append( + {"to_symbol": c.to_symbol, "file": c.evidence_file, + "line": c.evidence_line, "raw": c.raw}) + callers_by.setdefault(c.to_symbol, []).append( + {"from_symbol": c.from_symbol, "file": c.evidence_file, + "line": c.evidence_line, "raw": c.raw}) + else: + unresolved_by.setdefault(c.from_symbol, []).append( + {"to_name": c.to_name, "file": c.evidence_file, + "line": c.evidence_line, "raw": c.raw, + "reason": "unresolved: dynamic or cross-module target not in this repo"}) + pages = [] + for d in sg.symbols: + callers = callers_by.get(d.id, []) + callees = callees_by.get(d.id, []) + unresolved = unresolved_by.get(d.id, []) + pages.append({ + "id": f"symbol/{d.id}", "kind": "symbol", "title": d.name, + "symbol": d.id, "symbol_kind": d.kind, "module": d.module_id, + "parent": d.parent, "is_public": d.is_public, + "definition": {"file": d.file, "line": d.line}, + "callers": callers, "callees": callees, "unresolved_calls": unresolved, + "boundary": _symbol_boundary(len(callers) + len(callees))}) + return pages + + +def build_wiki_pack(root: Path | str, repo_name: str | None = None) -> dict: + """The whole wiki as one sealed, portable, deterministic JSON pack.""" + root = Path(root).resolve() + g = build_internals(root, repo_name) + sg = build_symbol_graph(root, repo_name) + docs = discover_docs(root) + commit = head_commit(root) + clustered = len(g.modules) > CLUSTER_THRESHOLD + if clustered: + body = _package_pages(g) + arch = _architecture_page(_package_graph_pack(g), "package", len(g.edges)) + else: + body = _module_pages(g) + arch = _architecture_page(_module_graph_pack(g), "module", len(g.edges)) + symbol_pages = _symbol_pages(sg) + pages = [_overview_page(g, docs, commit), arch, *body, *symbol_pages, _docs_page(docs)] + inputs = {"modules": len(g.modules), "internal_edges": len(g.edges), + "docs": len(docs), "clustered": clustered, + "symbols": len(sg.symbols), "symbol_pages": len(symbol_pages), + "resolved_symbol_calls": sg.coverage.resolved_calls, + "coverage_complete": g.coverage.complete} + manifest = build_manifest(pages, repo=g.repo, commit=commit, + inputs=inputs, tool_version=__version__) + return {"schema": WIKI_SCHEMA, "repo": g.repo, "commit": commit, + "pages": pages, "manifest": manifest} diff --git a/client-plugin/server/src/index_graph/wiki/seal.py b/client-plugin/server/src/index_graph/wiki/seal.py new file mode 100644 index 0000000..e9614d7 --- /dev/null +++ b/client-plugin/server/src/index_graph/wiki/seal.py @@ -0,0 +1,213 @@ +"""Seal and verify the wiki artifact: page hashes, graph re-derivation, commit pin. + +A page hash is the canonical SHA-256 of the page object (docs/PROTOCOL.md). +The verdict is one of three words, MATCH, DRIFT, or UNVERIFIABLE, never a +fourth, with exit codes 0/1/2 to match the existing verify contract. DRIFT +means a page was tampered with, the wiki claims a module edge the real graph +does not contain, or the repo moved off the pinned commit. UNVERIFIABLE means +the artifact cannot be evaluated at all. A verifier that cannot fail on a +known-bad input is not a verifier; the negative fixtures live in the tests. +""" +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +from ..certify.certificate import canonical_sha + +WIKI_SCHEMA = "index.wiki/1" +VERIFICATION_SCHEMA = "index.wiki-verification/1" +VERDICT_EXIT = {"MATCH": 0, "DRIFT": 1, "UNVERIFIABLE": 2} + +# The data island the HTML artifact embeds; render and extract share one truth. +EMBED_OPEN = '" + + +def head_commit(root: Path | str) -> str: + """Full HEAD sha of the repo at root, or "unversioned" for a non-git root.""" + try: + out = subprocess.run(["git", "-C", str(root), "rev-parse", "HEAD"], + capture_output=True, text=True, timeout=10) + except (OSError, subprocess.TimeoutExpired): + return "unversioned" + sha = out.stdout.strip() + return sha if out.returncode == 0 and sha else "unversioned" + + +def build_manifest(pages: list[dict], *, repo: str, commit: str, + inputs: dict, tool_version: str) -> dict: + return { + "schema": WIKI_SCHEMA, + "repo": repo, + "commit": commit, + "tool_version": tool_version, + "pages": [{"id": p["id"], "sha256": canonical_sha(p)} for p in pages], + "inputs": inputs, + } + + +def claimed_module_edges(pages: list[dict]) -> set[tuple[str, str]]: + """Every module-level edge the wiki asserts, from module and package pages.""" + edges: set[tuple[str, str]] = set() + for page in pages: + if page.get("kind") == "module": + for imp in page.get("imports", []): + edges.add((page.get("module", ""), imp.get("to", ""))) + for dep in page.get("dependents", []): + edges.add((dep.get("from", ""), page.get("module", ""))) + elif page.get("kind") == "package": + for group in list(page.get("imports", [])) + list(page.get("dependents", [])): + for via in group.get("via", []): + edges.add((via.get("from", ""), via.get("to", ""))) + return edges + + +def claimed_symbol_calls(pages: list[dict]) -> set[tuple[str, str]]: + """Every resolved symbol-call edge (caller, callee) the wiki asserts, drawn + from the callers/callees of each symbol page. An unresolved reference (which + the pages surface separately, without a to_symbol) is never a claimed edge.""" + edges: set[tuple[str, str]] = set() + for page in pages: + if page.get("kind") != "symbol": + continue + sym = page.get("symbol", "") + for callee in page.get("callees", []): + edges.add((sym, callee.get("to_symbol", ""))) + for caller in page.get("callers", []): + edges.add((caller.get("from_symbol", ""), sym)) + return edges + + +def _structural_gap(artifact: object) -> str | None: + if not isinstance(artifact, dict): + return "artifact is not a JSON object" + if artifact.get("schema") != WIKI_SCHEMA: + return f"artifact schema must be {WIKI_SCHEMA}" + pages, manifest = artifact.get("pages"), artifact.get("manifest") + if not isinstance(pages, list) or not pages: + return "artifact carries no pages" + if any(not isinstance(p, dict) or "id" not in p for p in pages): + return "artifact carries a page without an id" + if not isinstance(manifest, dict) or not isinstance(manifest.get("pages"), list): + return "artifact carries no sealing manifest" + return None + + +def _hash_findings(artifact: dict) -> list[dict]: + listed = {e.get("id"): e.get("sha256") + for e in artifact["manifest"]["pages"] if isinstance(e, dict)} + findings: list[dict] = [] + seen: set = set() + for page in artifact["pages"]: + pid = page["id"] + seen.add(pid) + if pid not in listed: + findings.append({"rule": "page-unlisted", + "detail": f"page {pid!r} is not sealed by the manifest"}) + elif canonical_sha(page) != listed[pid]: + findings.append({"rule": "page-tampered", + "detail": f"page {pid!r} does not match its sealed hash"}) + for pid in sorted(set(listed) - seen, key=str): + findings.append({"rule": "page-missing", + "detail": f"manifest seals page {pid!r} " + "but the artifact does not carry it"}) + return findings + + +def _report(verdict: str, findings: list[dict], *, pages_checked: int, + edges_checked: int, recheck: str) -> dict: + return {"schema": VERIFICATION_SCHEMA, "verdict": verdict, "findings": findings, + "pages_checked": pages_checked, "edges_checked": edges_checked, + "recheck": recheck} + + +def verify_wiki(artifact: object, root: Path | str, *, recheck: str = "") -> dict: + """Recompute page hashes and the graph derivation against the current tree.""" + root = Path(root) + recheck = recheck or f'index wiki --verify --root "{root}"' + gap = _structural_gap(artifact) + if gap is None and not root.is_dir(): + gap = f"root not found: {root}" + if gap: + return _report("UNVERIFIABLE", [{"rule": "artifact", "detail": gap}], + pages_checked=0, edges_checked=0, recheck=recheck) + findings = _hash_findings(artifact) + from ..internals import build_internals + real = {(e.from_id, e.to_id) for e in build_internals(root).edges} + claimed = claimed_module_edges(artifact["pages"]) + for frm, to in sorted(claimed - real): + findings.append({"rule": "edge-not-in-graph", + "detail": f"wiki claims module edge {frm} -> {to} but the " + "graph derived from the current tree does not contain it"}) + from ..symbols import build_symbol_graph, symbol_graph_to_claims + real_symbol = symbol_graph_to_claims(build_symbol_graph(root)) + claimed_symbol = claimed_symbol_calls(artifact["pages"]) + for frm, to in sorted(claimed_symbol - real_symbol): + findings.append({"rule": "symbol-call-not-in-graph", + "detail": f"wiki claims symbol call {frm} -> {to} but the " + "symbol graph derived from the current tree does " + "not contain it"}) + pinned, current = artifact["manifest"].get("commit"), head_commit(root) + if pinned != current: + findings.append({"rule": "commit-moved", + "detail": f"wiki is pinned to {pinned} " + f"but the current tree is at {current}"}) + else: + # Several pages assert content that no structured edge/hash check + # re-derives once the manifest is resealed: the architecture DIAGRAM + # (rendered svg/mermaid strings), the OVERVIEW facts (coverage, counts, + # entry points), and the DOCS prose (sealed but never re-read from + # source). Re-derive the whole pack from the tree (deterministic) and + # confirm each such page is exactly its fresh re-render. Only compared + # at the pinned commit, so a moved tree is named by commit-moved, not + # double-counted here. + from .pack import build_wiki_pack + fresh = {p.get("kind"): p for p in build_wiki_pack(root)["pages"]} + _CHECKS = (("architecture", "architecture-diagram-drift", + "the architecture diagram depicts structure the code does not have"), + ("overview", "overview-not-in-graph", + "the overview asserts facts the graph derived from the tree does not have"), + ("docs", "docs-not-in-source", + "a docs page asserts prose not re-derivable from the source markdown")) + for kind, rule, detail in _CHECKS: + stored = next((p for p in artifact["pages"] + if p.get("kind") == kind), None) + fresh_page = fresh.get(kind) + if stored is not None and fresh_page is not None and stored != fresh_page: + findings.append({"rule": rule, "detail": detail}) + return _report("MATCH" if not findings else "DRIFT", findings, + pages_checked=len(artifact["pages"]), + edges_checked=len(claimed) + len(claimed_symbol), recheck=recheck) + + +def extract_embedded_pack(html_text: str) -> dict: + """Pull the sealed pack back out of a rendered HTML artifact.""" + start = html_text.find(EMBED_OPEN) + if start < 0: + raise ValueError("no embedded wiki-data island found") + start += len(EMBED_OPEN) + end = html_text.find(EMBED_CLOSE, start) + if end < 0: + raise ValueError("embedded wiki-data island is unterminated") + return json.loads(html_text[start:end]) + + +def load_artifact(path: Path | str) -> dict: + text = Path(path).read_text(encoding="utf-8") + if text.lstrip().startswith("<"): + return extract_embedded_pack(text) + return json.loads(text) + + +def run_verify(artifact_path: Path | str, root: Path | str) -> dict: + """The shared CLI/MCP verification payload; an unreadable artifact is UNVERIFIABLE.""" + recheck = f'index wiki --verify "{artifact_path}" --root "{root}"' + try: + artifact = load_artifact(artifact_path) + except (OSError, ValueError) as exc: + return _report("UNVERIFIABLE", + [{"rule": "artifact", "detail": f"cannot read artifact: {exc}"}], + pages_checked=0, edges_checked=0, recheck=recheck) + return verify_wiki(artifact, root, recheck=recheck) diff --git a/client-plugin/server/src/index_graph/wiki/serve.py b/client-plugin/server/src/index_graph/wiki/serve.py new file mode 100644 index 0000000..fe65f32 --- /dev/null +++ b/client-plugin/server/src/index_graph/wiki/serve.py @@ -0,0 +1,141 @@ +"""`index serve`: a stdlib http.server that serves the verified wiki on demand. + +The URL-swap experience: GET /// reconstructs the git +URL https:////, runs the EXISTING verified-wiki generation +(the same clone -> derive -> clean-up code path as `index wiki `), and +serves the self-contained HTML. Consent-clean by construction: generation is +on demand only, nothing is crawled or pre-indexed, robots.txt disallows +indexing, and every page states that the wiki derives structure and defers to +the repo owner's authored docs. This is the LOCAL server component; deploying +or hosting it anywhere is a separate operator decision. +""" +from __future__ import annotations + +import re +import shutil +import tempfile +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from .cli import CloneError, clone_repo +from .html import render_wiki_html +from .pack import build_wiki_pack +from .serve_pages import ROBOTS_TXT, error_page, inject_banner, landing_page + +# A route segment: a plain forge/org/repo token. No slashes, no traversal, no +# leading dot, no scheme or userinfo. Deliberately strict; anything else is 400. +_SEGMENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +_ALLOWED_HOSTS = None # None = any host of the right shape; a set narrows it. + + +class RouteError(ValueError): + """A request path is not a valid /// route. The message is + a plain reason, safe to show a user, with no traceback.""" + + +def parse_route(path: str) -> tuple[str, str, str]: + """Parse /// into (host, org, repo), or raise + RouteError. Tolerates a trailing slash and a trailing `.git` on the repo.""" + trimmed = path.split("?", 1)[0].split("#", 1)[0].strip("/") + if trimmed.endswith(".git"): + trimmed = trimmed[:-4] + parts = trimmed.split("/") + if len(parts) != 3: + raise RouteError( + "a repo route is host/org/repo (e.g. /github.com/org/repo); " + "this path is not that shape") + host, org, repo = parts + for label, seg in (("host", host), ("org", org), ("repo", repo)): + if not _SEGMENT.match(seg): + raise RouteError(f"the {label} segment {seg!r} is not a plain " + "host/org/repo token") + if "." not in host: + raise RouteError(f"the host segment {host!r} is not a forge host") + if _ALLOWED_HOSTS is not None and host not in _ALLOWED_HOSTS: + raise RouteError(f"forge host {host!r} is not on the allow-list") + return host, org, repo + + +def build_git_url(host: str, org: str, repo: str) -> str: + """Reconstruct the https git URL from a parsed route. Always https, never a + scp-style or ssh URL, so only http(s) forge URLs are ever cloned.""" + return f"https://{host}/{org}/{repo}" + + +def wiki_for_route(path: str) -> str: + """Derive and render the verified wiki for a route, banner injected. Reuses + the `index wiki ` path: shallow clone, derive, always clean up.""" + host, org, repo = parse_route(path) + url = build_git_url(host, org, repo) + dest = Path(tempfile.mkdtemp(prefix="index-serve-")) + try: + clone_repo(url, dest) + pack = build_wiki_pack(dest, repo_name=repo) + return inject_banner(render_wiki_html(pack)) + finally: + shutil.rmtree(dest, ignore_errors=True) + + +class _WikiHandler(BaseHTTPRequestHandler): + server_version = "index-serve" + + def _send(self, status: int, body: bytes, ctype: str) -> None: + self.send_response(status) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("X-Robots-Tag", "noindex, nofollow") + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + + def do_GET(self) -> None: # noqa: N802 (http.server naming) + route = self.path.split("?", 1)[0] + if route == "/": + return self._send(200, landing_page(), "text/html; charset=utf-8") + if route == "/robots.txt": + return self._send(200, ROBOTS_TXT.encode("utf-8"), + "text/plain; charset=utf-8") + if route == "/favicon.ico": + return self._send(204, b"", "image/x-icon") + self._serve_wiki(route) + + do_HEAD = do_GET + + def _serve_wiki(self, route: str) -> None: + try: + html = wiki_for_route(route) + except RouteError as exc: + return self._send(400, error_page(400, str(exc)), + "text/html; charset=utf-8") + except CloneError as exc: + reason = f"could not build the wiki: {exc}" + return self._send(502, error_page(502, reason), + "text/html; charset=utf-8") + self._send(200, html.encode("utf-8"), "text/html; charset=utf-8") + + def log_message(self, *args) -> None: # keep the server quiet in tests + return + + +def make_server(host: str = "127.0.0.1", port: int = 8000) -> ThreadingHTTPServer: + """Bind a threading HTTP server on (host, port). Default loopback; port 0 + picks an ephemeral port (used by the tests). The caller runs serve_forever.""" + return ThreadingHTTPServer((host, port), _WikiHandler) + + +def serve_forever(host: str = "127.0.0.1", port: int = 8000) -> int: + """Bind and run the server until interrupted. Prints the bound address and + the consent-clean posture, then blocks.""" + server = make_server(host, port) + bound_host, bound_port = server.server_address + print(f"index serve: http://{bound_host}:{bound_port}/ " + "(on-demand verified wiki; robots.txt disallows indexing)") + print("derive-not-generate, commit-pinned, re-checkable; " + "defers to the repo owner's authored docs. Ctrl-C to stop.") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nindex serve: stopped") + finally: + server.server_close() + return 0 diff --git a/client-plugin/server/src/index_graph/wiki/serve_pages.py b/client-plugin/server/src/index_graph/wiki/serve_pages.py new file mode 100644 index 0000000..e91e7c1 --- /dev/null +++ b/client-plugin/server/src/index_graph/wiki/serve_pages.py @@ -0,0 +1,95 @@ +"""Static pages and the consent-clean banner for the on-demand wiki server. + +Kept separate from serve.py so both stay under the file-size ceiling. Every +page states the same posture: the wiki DERIVES structure from the dependency +graph, generates no prose, is commit-pinned and re-checkable, and defers to +the repo owner's authored docs. Nothing here crawls, pre-indexes, or publishes. +""" +from __future__ import annotations + +from html import escape + +from ..viz.theme import css_variables + +# One sentence reused verbatim on the landing page and injected into every +# served wiki, so the derive-not-generate posture is never missing. +DERIVE_BANNER = ( + "This wiki derives structure from the dependency graph and does not " + "generate prose. It is commit-pinned and re-checkable with " + "index wiki --verify, and it defers to the repo owner's authored docs." +) + +_PAGE_CSS = ( + "body{margin:0;background:var(--bg);color:var(--ink);" + "font-family:var(--font-body);line-height:1.5}" + "main{max-width:44rem;margin:0 auto;padding:2rem 1.2rem}" + "h1{font-family:var(--font-mono);font-size:1.4rem}" + "code{font-family:var(--font-mono)}" + ".note{border:1px solid var(--hairline);border-radius:6px;padding:.8rem 1rem;" + "background:rgba(70,54,232,.05);font-size:.92rem}" + "a{color:var(--accent)}" + "footer{margin-top:2rem;font-family:var(--font-mono);font-size:.78rem;" + "color:var(--muted)}" +) + +# The consent-clean banner as an HTML fragment, injected into served wikis. +BANNER_HTML = ( + '
        ' + f"{escape(DERIVE_BANNER)}
        " +) + +ROBOTS_TXT = "User-agent: *\nDisallow: /\n" + + +def _shell(title: str, body: str) -> bytes: + return ( + "" + '' + '' + f"{escape(title)}" + f"" + f"
        {body}
        " + ).encode("utf-8") + + +def landing_page() -> bytes: + """The root page: how to use the server, stated in consent-clean terms.""" + body = ( + "

        index serve

        " + "

        A local server that derives a verified wiki for one repository " + "on demand. Request a repo by its forge path and index " + "shallow-clones it, derives the wiki from its dependency graph, serves " + "the self-contained page, and removes the clone.

        " + "

        Try a path of the shape " + "/<forge-host>/<org>/<repo>, for example " + "/github.com/org/repo.

        " + f'

        {escape(DERIVE_BANNER)}

        ' + "

        Generation is on demand only: nothing is crawled or pre-indexed, and " + "this local server publishes nothing. Deploying or hosting it anywhere is " + "a separate operator decision.

        " + "
        index wiki, derived from the module graph, never generated " + "prose. robots.txt disallows indexing.
        " + ) + return _shell("index serve", body) + + +def error_page(status: int, reason: str) -> bytes: + """A plain error page, never a stack trace.""" + body = ( + f"

        {status}

        " + f"

        {escape(reason)}

        " + '

        back to the landing page

        ' + ) + return _shell(f"index serve: {status}", body) + + +def inject_banner(wiki_html: str) -> str: + """Insert the consent-clean banner just inside the served wiki's .""" + marker = "" + idx = wiki_html.find(marker) + if idx < 0: + return BANNER_HTML + wiki_html + cut = idx + len(marker) + return wiki_html[:cut] + BANNER_HTML + wiki_html[cut:] diff --git a/client-plugin/server/src/index_graph/workbench.py b/client-plugin/server/src/index_graph/workbench.py new file mode 100644 index 0000000..64b4410 --- /dev/null +++ b/client-plugin/server/src/index_graph/workbench.py @@ -0,0 +1,291 @@ +"""The index workbench: every capability index derives, composed into ONE +self-contained page. + +Separately, index already ships a verified wiki, an interactive atlas, a +context lens, freshness fingerprints, and a symbol/dependency graph — each its +own artifact. The workbench folds the single-pass surfaces into one pack a +single HTML shell renders: a map you navigate, the docs that describe it, the +context lens over its token budget, and a health panel with the freshness +fingerprint, cycles, and salience audit. Every dependency edge carries its +file:line evidence; nothing is authored, everything re-derives. + +Honest scope (stated on the page, not hidden): freshness/drift VERDICTS need a +prior pinned snapshot to diff against. A single `--root` pass cannot emit a +drift verdict, so the workbench shows the current fingerprint (the baseline a +later `index drift` compares to) and the structural health that IS computable +in one pass — never a fabricated FRESH/STALE badge. +""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +from .context.lens import build_lens_pack +from .freshness.fingerprint import workspace_fingerprint +from .graph.build import DependencyGraph +from .knowledge.atlas import build_atlas_pack +from .knowledge.markdown import render_markdown +from .viz.atlas_layout import build_atlas_layout +from .viz.atlas_svg import render_atlas_svg + +SCHEMA = "project-telos.workbench/v1" +TOOL = "index.workbench" +ACTION_SCHEMA = "project-telos.flagship-action/v1" + +# Page-weight budgets. Large workspaces (2k+ docs) produced a 60MB page that +# choked renderers — a robustness failure. Budgets are HONEST: what is dropped +# is counted and shown on the page ("N of M"), search metadata stays complete, +# and every cap is overridable at the CLI. Never a silent truncation. +MAX_DOC_BODIES = 200 # rendered markdown bodies embedded in the page +MAX_MAP_DOCS_PER_REPO = 6 # doc satellites drawn under one repo on the map +MAX_MAP_BAND_DOCS = 40 # cross-cutting (band) docs drawn on the map +MAX_MENTIONS = 2000 # weakest-tier edges embedded (describes/links-to + # always ship in full; mention drops are counted) + + +def load_spine(spine_dir: str | Path | None, root: Path) -> dict: + """Ingest captured flagship-action envelopes (the peers' own doctor/status + output) and detect on-disk peer surfaces. FAIL CLOSED: a file that is not a + valid flagship-action envelope is skipped WITH ITS REASON recorded, never + guessed at. Peers stay peers: the forum ledger is reported as present with + the forum command to inspect it — index never parses a peer's internals.""" + tools: list[dict] = [] + skipped: list[dict] = [] + ring: list[dict] = [] + if spine_dir is not None: + for p in sorted(Path(spine_dir).glob("*.json")): + try: + env = json.loads(p.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + skipped.append({"file": p.name, "reason": f"unreadable: {exc}"}) + continue + if env.get("schema") != ACTION_SCHEMA or "tool" not in env: + skipped.append({"file": p.name, + "reason": f"not a {ACTION_SCHEMA} envelope"}) + continue + checks = (env.get("native") or {}).get("checks", []) + tools.append({ + "tool": env["tool"], "version": env.get("tool_version", ""), + "command": env.get("command", ""), "status": env.get("status", ""), + "checks": [{"name": c.get("name", ""), "status": c.get("status", "")} + for c in checks], + "source_file": p.name, + }) + for na in env.get("next_actions", []): + ring.append({"from": env["tool"], "to": na.get("tool", ""), + "action": na.get("action", ""), + "reason": na.get("reason", "")}) + ledger = root / ".telos" / "forum-ledger" + surfaces = [] + if ledger.is_dir(): + surfaces.append({ + "peer": "forum", "surface": "causal ledger", + "path": str(ledger), "entries": sum(1 for _ in ledger.iterdir()), + "inspect": "forum ledger summary (or MCP forum.ledger.summary)"}) + return {"tools": sorted(tools, key=lambda t: t["tool"]), + "ring": sorted(ring, key=lambda e: (e["from"], e["to"])), + "skipped": skipped, "peer_surfaces": surfaces, + "capture": ("each flagship's doctor/status --json IS the envelope; " + "drop them in a directory and pass --spine-dir")} + + +def _evidence(rel: dict) -> list[dict]: + """The file:line signals behind a dependency edge — the 'why' of the map.""" + out = [] + for s in rel.get("signals", []): + if s.get("file"): + out.append({"kind": s.get("kind", ""), "file": s["file"], "line": s.get("line")}) + return out + + +def _repo_view(pack: dict, fresh: dict) -> list[dict]: + sal = pack.get("salience", {}) + roles = pack.get("roles", {}) + fresh_repos = fresh.get("repos", {}) + describes: dict[str, list[str]] = {} + for e in pack.get("knowledge_edges", []): + if e["type"] == "describes" and e["to_kind"] == "repo": + describes.setdefault(e["to"], []).append(e["from"]) + deps: dict[str, list[dict]] = {} + for r in pack.get("relations", []): + if r.get("external"): + continue + deps.setdefault(r["from"], []).append({ + "to": r["to"], "confidence": r.get("confidence", ""), + "in_cycle": r.get("in_cycle", False), "evidence": _evidence(r)}) + out = [] + for repo in pack.get("repos", []): + name = repo["name"] + s = sal.get(name, {}) + out.append({ + "name": name, + "roles": roles.get(name, []), + "ecosystems": repo.get("ecosystems", []), + "description": repo.get("description", ""), + "markers": repo.get("markers", []), + "in_degree": s.get("in_degree", 0), + "out_degree": s.get("out_degree", 0), + "depends_on": sorted(deps.get(name, []), key=lambda d: d["to"]), + "documented_by": sorted(describes.get(name, [])), + "fingerprint": fresh_repos.get(name, ""), + }) + return sorted(out, key=lambda r: r["name"]) + + +def _summary(pack: dict, repos: list[dict]) -> dict: + role_counts: dict[str, int] = {} + for r in repos: + for role in (r["roles"] or ["untyped"]): + role_counts[role] = role_counts.get(role, 0) + 1 + edge_types: dict[str, int] = {} + for e in pack.get("knowledge_edges", []): + edge_types[e["type"]] = edge_types.get(e["type"], 0) + 1 + return { + "repos": len(repos), + "docs": len(pack.get("docs", [])), + "relations": len([r for r in pack.get("relations", []) if not r.get("external")]), + "external_relations": len([r for r in pack.get("relations", []) if r.get("external")]), + "knowledge_edges": len(pack.get("knowledge_edges", [])), + "cycles": len(pack.get("cycles", [])), + "warnings": len(pack.get("warnings", [])) + len(pack.get("knowledge_warnings", [])), + "role_counts": dict(sorted(role_counts.items())), + "edge_types": dict(sorted(edge_types.items())), + "top_salience": [r["name"] for r in sorted( + repos, key=lambda r: (-r["in_degree"], -r["out_degree"], r["name"]))[:8]], + } + + +def _rank_docs(pack: dict) -> list[str]: + """Doc ids by connectivity (strongest knowledge edges first), then id. + Deterministic; drives which bodies are embedded and which nodes are drawn.""" + weight = {"describes": 3, "links-to": 2, "mentions": 1} + score: dict[str, int] = {d["id"]: 0 for d in pack.get("docs", [])} + for e in pack.get("knowledge_edges", []): + w = weight.get(e["type"], 0) + if e["from"] in score: + score[e["from"]] += w + if e.get("to_kind") == "doc" and e["to"] in score: + score[e["to"]] += w + return sorted(score, key=lambda i: (-score[i], i)) + + +def _map_pack(pack: dict, ranked: list[str]) -> dict: + """A copy of the pack with docs capped for the MAP ONLY (satellites per + repo + band docs), so the SVG stays renderable on huge workspaces. The + full doc list remains in the workbench data for search and the Docs view.""" + describes: dict[str, str] = {} + for e in sorted(pack.get("knowledge_edges", []), + key=lambda e: (e["from"], e["type"], e["to_kind"], e["to"])): + if e["type"] == "describes" and e["from"] not in describes: + describes[e["from"]] = e["to"] + rank = {rid: i for i, rid in enumerate(ranked)} + per_repo: dict[str, int] = {} + band = 0 + keep: set[str] = set() + for d in sorted(pack.get("docs", []), key=lambda d: rank.get(d["id"], 1 << 30)): + target = describes.get(d["id"]) + if target is not None: + if per_repo.get(target, 0) < MAX_MAP_DOCS_PER_REPO: + per_repo[target] = per_repo.get(target, 0) + 1 + keep.add(d["id"]) + elif band < MAX_MAP_BAND_DOCS: + band += 1 + keep.add(d["id"]) + capped = dict(pack) + capped["docs"] = [d for d in pack.get("docs", []) if d["id"] in keep] + capped["knowledge_edges"] = [ + e for e in pack.get("knowledge_edges", []) + if e["type"] != "mentions" + and e["from"] in keep and (e.get("to_kind") != "doc" or e["to"] in keep)] + return capped + + +def build_workbench_pack( + graph: DependencyGraph, + docs: list, + repo_dirs: dict[str, str], + *, + root: Path | str, + token_budget: int = 6000, + spine_dir: str | Path | None = None, + max_doc_bodies: int = MAX_DOC_BODIES, +) -> dict: + """Compose the atlas, docs, freshness fingerprint, lens, and health into one + deterministic, self-contained pack. The SVG (map markup) rides along under + 'svg'; everything else is JSON the shell renders and the receipt seals. + Page-weight budgets (MAX_*) keep huge workspaces renderable; every drop is + counted in `summary`/`doc_meta` and stated on the page.""" + pack = build_atlas_pack(graph, docs, repo_dirs) + ranked_docs = _rank_docs(pack) + structural = [e for e in pack.get("knowledge_edges", []) + if e["type"] != "mentions"] + mentions = [e for e in pack.get("knowledge_edges", []) + if e["type"] == "mentions"] + kept_mentions = sorted(mentions, key=lambda e: (e["from"], e["to"]))[:MAX_MENTIONS] + pack["knowledge_edges"] = structural + kept_mentions + embed = set(ranked_docs[:max_doc_bodies]) + body_of = {d.rel_path: d.body for d in docs} + pack["doc_html"] = {rid: render_markdown(body_of[rid]) + for rid in sorted(embed) if rid in body_of} + map_capped = _map_pack(pack, ranked_docs) + svg = render_atlas_svg(build_atlas_layout(map_capped, include_external=False)) + fresh = workspace_fingerprint({n.name: Path(n.path) for n in graph.repos}) + repos = _repo_view(pack, fresh) + lens = build_lens_pack(graph, root=root, token_budget=token_budget) + lens_order = [{k: o[k] for k in ("name", "cost", "roles", "salience")} + for o in lens["replay"]["order"]] # page never reads source_refs + wb = { + "schema": SCHEMA, + "tool": TOOL, + "root": str(root), + "summary": _summary(pack, repos), + "repos": repos, + "docs": sorted(pack.get("docs", []), key=lambda d: d["id"]), + "doc_html": pack["doc_html"], + "knowledge_edges": pack.get("knowledge_edges", []), + "cycles": [list(c) for c in pack.get("cycles", [])], + "warnings": list(pack.get("warnings", [])), + "knowledge_warnings": list(pack.get("knowledge_warnings", [])), + "salience_audit": pack.get("salience_audit", []), + "freshness": { + "schema": fresh.get("schema", ""), + "root_sha256": fresh.get("root", ""), + "repos": fresh.get("repos", {}), + "note": ("current fingerprint only; a FRESH/STALE drift verdict " + "requires a prior snapshot"), + "recheck": "index freshness --cert CERT --root ROOT; index drift --from-snap A --to-snap B", + }, + "lens": { + "verdict": lens["envelope"]["verification_verdict"], + "budget": lens["envelope"]["budget"], + "replay": {"rule": lens["replay"]["rule"], + "base_tokens": lens["replay"]["base_tokens"], + "order": lens_order}, + }, + "doc_meta": { + "total": len(pack.get("docs", [])), + "bodies_embedded": len(pack["doc_html"]), + "map_docs": len(map_capped["docs"]), + "mentions_total": len(mentions), + "mentions_embedded": len(kept_mentions), + "bodies_note": ("bodies beyond the budget are not embedded; the doc " + "list and search stay complete — open the file at " + "its id path, or raise --max-doc-bodies"), + }, + "spine": load_spine(spine_dir, Path(root)), + } + # seal the rendered map's hash into the receipt: the SVG is presented as + # sealed, so its markup must be bound. The large markup itself stays out of + # the sealed JSON body, but its content hash is inside it, so a stranger + # re-derives _sha(wb["svg"]) == wb["svg_sha256"] and tampering the map + # breaks the receipt. + wb["svg_sha256"] = _sha(svg) + wb["receipt_sha256"] = _sha(wb) + wb["svg"] = svg # markup, added after sealing the data + return wb + + +def _sha(value: object) -> str: + data = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(data).hexdigest() diff --git a/scripts/build_client_package.py b/scripts/build_client_package.py index 6d6d7a1..7d86cf2 100644 --- a/scripts/build_client_package.py +++ b/scripts/build_client_package.py @@ -72,6 +72,30 @@ def entries(root): result[path.relative_to(root).as_posix()] = path.read_bytes() return result +VENDORED = "server/src/" + + +def vendored_payload(): + """The files the source ZIP places under server/src/, also committed in the plugin folder.""" + return {VENDORED + k: v for k, v in entries(ROOT / "src").items()} + + +def plugin_entries(): + """Plugin folder inputs without the vendored copy, which is rebuilt from src/.""" + return {k: v for k, v in entries(ROOT / "client-plugin").items() if not k.startswith(VENDORED)} + + +def sync_vendored(): + """Rewrite client-plugin/server/src from src/, deleting stale files.""" + target = ROOT / "client-plugin" / VENDORED + if target.exists(): + shutil.rmtree(target) + for name, data in vendored_payload().items(): + path = ROOT / "client-plugin" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data.replace(b"\r\n", b"\n")) + return target + def write_zip(path, payload): with zipfile.ZipFile(path, "x", compression=zipfile.ZIP_STORED) as archive: for name, data in sorted(payload.items()): @@ -90,7 +114,7 @@ def build(output, mode="release", native=False): raise ValueError("output must be a new directory outside source") source = entries(ROOT / "src") scripts = entries(ROOT / "scripts") - plugin = entries(ROOT / "client-plugin") + plugin = plugin_entries() tracked = set(git("ls-files").splitlines()) if mode == "release": candidates = {**{"src/"+k:v for k,v in source.items()}, @@ -102,7 +126,7 @@ def build(output, mode="release", native=False): **{"scripts/"+k: digest(v) for k,v in scripts.items()}, **{"client-plugin/"+k: digest(v) for k,v in plugin.items()}} output.mkdir(parents=True) - payload = {**plugin, **{"server/src/"+k:v for k,v in source.items()}} + payload = {**plugin, **{VENDORED+k:v for k,v in source.items()}} payload["LICENSE"] = (ROOT / "LICENSE").read_bytes() payload["SOURCE.json"] = (json.dumps(qualified, indent=2)+"\n").encode() payload["PAYLOAD-SHA256SUMS"] = checksums(payload) @@ -156,7 +180,7 @@ def build(output, mode="release", native=False): "pyinstaller":importlib.metadata.version("pyinstaller"), "command":command} current = {**{"src/"+k:digest(v) for k,v in entries(ROOT/"src").items()}, **{"scripts/"+k:digest(v) for k,v in entries(ROOT/"scripts").items()}, - **{"client-plugin/"+k:digest(v) for k,v in entries(ROOT/"client-plugin").items()}} + **{"client-plugin/"+k:digest(v) for k,v in plugin_entries().items()}} if current != source_hashes: raise ValueError("source changed during build") receipt["artifacts"] = {p.name:digest(p.read_bytes()) for p in paths} @@ -166,9 +190,16 @@ def build(output, mode="release", native=False): if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("output") + parser.add_argument("output", nargs="?") + parser.add_argument("--sync-vendored", action="store_true", + help="rewrite client-plugin/server/src from src/ and exit") parser.add_argument("--mode", choices=("dev","release"), default="release") parser.add_argument("--native", action="store_true") args = parser.parse_args() + if args.sync_vendored: + print(sync_vendored()) + raise SystemExit(0) + if args.output is None: + parser.error("output is required") for path in build(args.output, args.mode, args.native): print(path) diff --git a/tests/test_client_vendored.py b/tests/test_client_vendored.py new file mode 100644 index 0000000..f7c06a5 --- /dev/null +++ b/tests/test_client_vendored.py @@ -0,0 +1,76 @@ +"""The plugin folder must run on its own: vendored server code, size limits, isolated launch.""" +import json +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PLUGIN = ROOT / "client-plugin" +sys.path.insert(0, str(ROOT / "scripts")) +package = __import__("build_client_package") +SYNC = "python scripts/build_client_package.py --sync-vendored" + + +def _lf(data): + return data.replace(b"\r\n", b"\n") + + +def test_vendored_server_code_matches_the_builder(): + expected = {k: _lf(v) for k, v in package.vendored_payload().items()} + committed = {"server/src/" + p.relative_to(PLUGIN / "server/src").as_posix(): _lf(p.read_bytes()) + for p in (PLUGIN / "server/src").rglob("*") + if p.is_file() and "__pycache__" not in p.parts and p.suffix not in {".pyc", ".pyo"}} + missing = sorted(set(expected) - set(committed)) + extra = sorted(set(committed) - set(expected)) + changed = sorted(k for k in set(expected) & set(committed) if expected[k] != committed[k]) + assert not (missing or extra or changed), ( + f"client-plugin/server/src is out of date (missing {missing[:5]}, extra {extra[:5]}, " + f"changed {changed[:5]}). Run: {SYNC}") + + +def test_builder_inputs_do_not_count_the_vendored_copy_twice(): + assert not any(k.startswith("server/src/") for k in package.plugin_entries()) + + +def test_plugin_folder_fits_directory_limits(): + files = [p for p in PLUGIN.rglob("*") if p.is_file() and "__pycache__" not in p.parts] + assert len(files) <= 512, len(files) + large = [(p.relative_to(PLUGIN).as_posix(), p.stat().st_size) for p in files if p.stat().st_size >= 256 * 1024] + assert not large, large + assert not list(PLUGIN.rglob(".gitattributes")) + + +def _launch(folder, workspace, requests): + server = json.loads((folder / ".mcp.json").read_text(encoding="utf-8"))["mcpServers"]["index"] + defaults = {"workspace": str(workspace), "state_directory": ""} + args = [a.replace("${CLAUDE_PLUGIN_ROOT}", str(folder)) for a in server["args"]] + for key, value in defaults.items(): + args = [a.replace("${user_config." + key + "}", value) for a in args] + assert not any("${" in a for a in args), args + command = [sys.executable if server["command"] == "python3" else server["command"], *args] + env = None + if server.get("env"): + import os + env = {**os.environ, **server["env"]} + wire = "".join(json.dumps(r) + "\n" for r in requests) + return subprocess.run(command, input=wire, capture_output=True, text=True, timeout=30, env=env, check=False) + + +def test_plugin_folder_alone_starts_and_lists_tools(tmp_path): + folder = tmp_path / "plugin" + shutil.copytree(PLUGIN, folder, ignore=shutil.ignore_patterns("__pycache__")) + workspace = tmp_path / "workspace" + workspace.mkdir() + requests = [{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}] + result = _launch(folder, workspace, requests) + assert result.returncode == 0, result.stderr + rows = [json.loads(line) for line in result.stdout.splitlines()] + assert rows[0]["result"]["serverInfo"]["name"] == "index-local" + names = {t["name"] for t in rows[1]["result"]["tools"]} + assert {"index.map", "index.select", "index.symbol-definition", "index.symbol-references"} <= names + shutil.rmtree(folder / "server/src") + result = _launch(folder, workspace, requests) + assert result.returncode == 1 and not result.stdout + assert result.stderr.strip() == "index: the server code is missing from the plugin folder. Reinstall the plugin."