diff --git a/.github/workflows/experiment.yml b/.github/workflows/experiment.yml index 0f8ba753..9574fa17 100644 --- a/.github/workflows/experiment.yml +++ b/.github/workflows/experiment.yml @@ -74,7 +74,21 @@ jobs: if: ${{ needs.latest_update.outputs.run == 'true' }} name: ${{ matrix.language }} LSP runs-on: ubuntu-latest - timeout-minutes: 45 + # Fourteen rows install a released producer and finish in minutes; the + # ninety-minute bound is theirs and stays exactly where it is. C and C++ + # build a compiler from source, and that is a property of the row rather + # than a defect inside it. Measured: three minutes of checkout, install and + # package build, then 56 minutes to a linked `clangd` on one runner and 107 + # on another in the same workflow, then the real-corpus lifecycle run. + # Ninety did not fit. 150 covers the fast runner comfortably and the slow + # one barely, and it is still a bound, so a lane that hangs is caught. Only + # the two rows that build a compiler get it. + # + # The restore below is what should keep an ordinary push away from that + # cost, and it is an expectation rather than a property: Actions caches are + # branch-scoped and evicted, so a first push on a fresh branch still pays + # the whole build, on whichever runner it draws. + timeout-minutes: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 150 || 90 }} strategy: fail-fast: false matrix: @@ -138,15 +152,56 @@ jobs: if: ${{ github.event_name != 'workflow_dispatch' || inputs.language == 'all' || inputs.language == matrix.language }} run: pnpm build + # C and C++ are the only rows whose producer is built rather than + # downloaded, and it is a pinned commit: the same bytes, reproduced from + # scratch, on every push. Restoring them instead is not a shortcut around + # the build but a removal of work that had no reason to happen twice. + # + # The key hashes every file the built bytes depend on. `catalog.mjs` is + # the one `setup` actually reads its commit from, so it has to be here — + # keying on the adapter's constant alone would leave the two bound only + # by a text assertion in a different workflow, and a divergence would hit + # the key, fail the `--version` check, rebuild, and then not re-save, + # because an exact hit has nothing to write. That is silent, permanent, + # full-cost rebuilding, so the binding is made structural instead. + # + # Restore and save are split so the save runs even when a later step + # fails. A campaign iterating on these lanes is exactly the case where + # the producer builds and the experiment does not, and `cache`'s combined + # form would discard an hour or more of correct build because a corpus + # assertion afterwards went red. + - name: Restore the pinned Clang producer + id: clang_producer + if: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && (github.event_name != 'workflow_dispatch' || inputs.language == 'all' || inputs.language == matrix.language) }} + uses: actions/cache/restore@v6 + with: + path: tests/experiment/.work/tools + key: clang-producer-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts', 'tests/experiment/src/catalog.mjs', 'tests/experiment/src/setup-language.mjs') }} + - name: Install language server if: ${{ github.event_name != 'workflow_dispatch' || inputs.language == 'all' || inputs.language == matrix.language }} run: pnpm --filter @samchon/graph-experiment run setup -- --language ${{ matrix.language }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Saved before the experiment runs, not after it, and only on a miss. + # The producer is proved by this point — `setup` refuses to finish unless + # the installed binary reports the pinned commit — and what follows is a + # corpus run whose failure says nothing about the compiler that was + # built. Waiting until the end would tie a correct build's survival to an + # unrelated assertion. + - name: Save the pinned Clang producer + if: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && steps.clang_producer.outputs.cache-hit != 'true' && (github.event_name != 'workflow_dispatch' || inputs.language == 'all' || inputs.language == matrix.language) }} + uses: actions/cache/save@v6 + with: + path: tests/experiment/.work/tools + key: ${{ steps.clang_producer.outputs.cache-primary-key }} + - name: Run LSP experiment if: ${{ github.event_name != 'workflow_dispatch' || inputs.language == 'all' || inputs.language == matrix.language }} run: pnpm --filter @samchon/graph-experiment start -- --language ${{ matrix.language }} + env: + SAMCHON_GRAPH_RUST_ANALYZER_HIR: ${{ github.workspace }}/tests/experiment/.work/tools/bin/samchon-rust-analyzer - name: Upload result if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.language == 'all' || inputs.language == matrix.language) }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a04be1e0..b299d5be 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,6 +81,9 @@ jobs: - name: Build run: pnpm run build + - name: Check provider support + run: pnpm provider-support + - name: Test Go sidecar working-directory: sidecars/go run: go test ./... diff --git a/README.md b/README.md index b84d9cf0..1c6e5b97 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ `@samchon/graph` is an MCP server that gives AI agents a code graph instead of source files. -It indexes a codebase in 16 languages into a graph of declarations and their relationships, and answers an agent's code questions from that index through a single tool. A compiler-owned provider supplies semantic edges where one is available, then the language server, and finally the separately packaged `@samchon/graph-sitter` best-effort fallback. +It indexes a codebase in 16 languages into a graph of declarations and the relationships each selected provider can defend, then answers an agent's code questions from that index through a single tool. Registered strict providers have compiler, analyzer, or semantic-index authority and may decline a build; the ordinary language server and separately packaged `@samchon/graph-sitter` remain explicit lower-authority fallbacks. Coding agents normally answer a code question by grepping the repository and reading file after file into context, and that reading is most of the token bill. The graph removes the need for it, and its own answers stay small in turn: they carry names, signatures, relationships, and source spans, never file bodies. @@ -41,7 +41,7 @@ A language server improves the graph with semantically resolved edges. Install t | Language | Server | Install | |---|---|---| -| TypeScript | `ttscgraph` / `ttscserver` | `npm i -D ttsc@^0.20.1 typescript` | +| TypeScript | `ttscserver` | `npm i -D ttsc@^0.23.0 typescript` | | Python | `pyright-langserver` | `npm i -D pyright` | | Go | `gopls` | `go install golang.org/x/tools/gopls@latest` | | Rust | `rust-analyzer` | `rustup component add rust-analyzer` | @@ -59,13 +59,132 @@ A language server improves the graph with semantically resolved edges. Install t Each server must be on `PATH`. If none is present for a file's language, that language falls back to the static indexer automatically. -Before the generic lane runs, indexing asks a registry of strict providers which languages they own. A provider states what its facts are grounded in — a compiler, a whole-project analyzer, or a precomputed semantic index — and which edge families it can prove; a snapshot that publishes outside those is rejected rather than merged. Whatever no provider claims falls through to the language server, and then to the static indexer. Every decline is one sentence naming the provider and the authority the build gave up, so a fallback is never mistaken for the strict result it replaced. - -The dump carries one `provenance` row per contributing provider: its authority, the fact families it proves, the producing tool and versions, a fingerprint of the inputs that decided the file set, and digests over the manifest and the published facts. Absent when no strict provider served the build. What a provider *did* to compute a generation is deliberately not recorded there — that belongs to one refresh rather than to the facts, and writing it down would make two dumps of the same unedited checkout differ. + +### Strict provider support + +_Generated from [`docs/provider-support.json`](https://github.com/samchon/compiler-graph/blob/master/docs/provider-support.json); do not edit this block by hand._ + +Strict selection is per registered provider and may decline for missing tools, incompatible options, or incomplete build metadata. Authority grades differ. A provider's `facts` list means it can defend those edge families; it is not a universal-completeness claim. Strict dumps carry provider/tool provenance plus universe, input-manifest, and content digests. The MCP result reports operation coverage and uncertainty, but does not promise #63's future complete producer-owned per-generation coverage contract. Generic language-server and static fallbacks remain valid lower-authority results and are identified as such. + +#### Capability + +| Provider | Languages | Authority | Defensible facts | Evidence | +| --- | --- | --- | --- | --- | +| `ttscgraph` | `typescript` | `compiler` | `exports`, `calls`, `accesses`, `instantiates`, `type_ref`, `extends`, `implements`, `overrides`, `renders` | [upstream](https://github.com/samchon/ttsc) / [route #63](https://github.com/samchon/compiler-graph/issues/63) | +| `samchon-graph-go` | `go` | `compiler` | `contains`, `exports`, `imports`, `calls`, `accesses`, `instantiates`, `type_ref`, `implements`, `dispatches`, `tests`, `references` | [upstream](https://github.com/scip-code/scip-go) / [route #63](https://github.com/samchon/compiler-graph/issues/63) | +| `samchon-graph-lua` | `lua` | `analyzer` | `references` | [upstream](https://github.com/LuaLS/lua-language-server) / [route #83](https://github.com/samchon/compiler-graph/issues/83) | +| `samchon-rust-analyzer-hir` | `rust` | `analyzer` | `contains`, `exports`, `imports`, `calls`, `accesses`, `instantiates`, `type_ref`, `extends`, `implements`, `overrides`, `dispatches`, `decorates`, `tests`, `references` | [upstream](https://github.com/samchon/rust-analyzer) / [route #72](https://github.com/samchon/compiler-graph/issues/72) | +| `clangd-snapshot` | `c`, `cpp` | `compiler` | `contains`, `exports`, `imports`, `calls`, `accesses`, `instantiates`, `type_ref`, `extends`, `implements`, `overrides`, `dispatches`, `references` | [upstream](https://github.com/samchon/llvm-project) / [route #73](https://github.com/samchon/compiler-graph/issues/73) | +| `scip-java` | `java`, `kotlin` | `semantic-index` | `contains`, `references` | [upstream](https://github.com/scip-code/scip-java) / [route #74](https://github.com/samchon/compiler-graph/issues/74) / [route #76](https://github.com/samchon/compiler-graph/issues/76) | +| `scip-dotnet` | `csharp` | `semantic-index` | **none** | [upstream](https://github.com/sourcegraph/scip-dotnet) / [route #75](https://github.com/samchon/compiler-graph/issues/75) | +| `scip-python` | `python` | `semantic-index` | `references` | [upstream](https://github.com/sourcegraph/scip-python) / [route #80](https://github.com/samchon/compiler-graph/issues/80) | +| `scip-ruby` | `ruby` | `semantic-index` | **none** | [upstream](https://github.com/sourcegraph/scip-ruby) / [route #81](https://github.com/samchon/compiler-graph/issues/81) | +| `scip-dart` | `dart` | `semantic-index` | **none** | [upstream](https://pub.dev/packages/scip_dart) / [route #84](https://github.com/samchon/compiler-graph/issues/84) | +| `scip-php` | `php` | `semantic-index` | **none** | [upstream](https://github.com/davidrjenni/scip-php) / [route #82](https://github.com/samchon/compiler-graph/issues/82) | + +#### Lifecycle + +These are current implementation modes, not future route claims. Preparation and native/export/resident phases are stated separately because the [experiment catalog](https://github.com/samchon/compiler-graph/blob/master/tests/experiment/src/catalog.mjs) and [cold measurement artifact](https://github.com/samchon/compiler-graph/blob/master/tests/benchmark/results/graph.json) prove different boundaries; the artifact reports whole end-to-end cells, not isolated phase timings. + +| Provider | Mode | Preparation | Native analysis | Export and merge | Reuse or resident state | +| --- | --- | --- | --- | --- | --- | +| `ttscgraph` | `resident-no-op-reuse; invalidated-closure shard deltas with a compatible producer` | A matching ttsc/TypeScript project and tsconfig/jsconfig/package inputs. | A compatible target-project ttsc checker owns one resident compiler process and its incremental semantic state. | Changed compiler-owned raw shards cross a versioned transaction; the client validates the complete manifest and adapts only upserts before atomic publication. | Unchanged requests reuse the exact snapshot; body edits reuse unaffected native and normalized shards, while build-universe changes reload safely. | +| `samchon-graph-go` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs. | The shipped exporter runs one compiler-owned go/packages batch against the selected build universe. | A changed-input batch emits and validates one whole-workspace graph before snapshot publication. | Unchanged inputs reuse the validated snapshot; no resident go/packages checker survives changed builds. | +| `samchon-graph-lua` | `unchanged-snapshot-reuse; full-rebuild-on-change` | LuaLS workspace configuration and the shipped readable exporter. | LuaLS analyzes the workspace and the shipped exporter asks its semantic VM for declaration references. | A changed-input run publishes one references-only whole-workspace graph. | Unchanged inputs reuse the validated snapshot; the current exporter is not a resident incremental session. | +| `samchon-rust-analyzer-hir` | `resident-no-op-reuse; invalidated-closure shard deltas; validated restart checkpoints` | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | The pinned rust-analyzer fork owns one resident HIR database and exports declarations, semantic relationships, diagnostics, coverage and unresolved boundaries from that exact analysis revision. | Content-addressed source shards cross a versioned LSP transaction; the client verifies producer identity, universe, complete manifests, shard digests and graph invariants before atomic publication. | No-op requests reuse the resident snapshot; interface changes invalidate dependent shards, while a complete consumer checkpoint restores the same generation after process restart. | +| `clangd-snapshot` | `resident-no-op-reuse; complete changed-TU/configuration replacement; content-addressed deltas` | A valid compilation database plus every source, header, generated input, command, target and working-directory identity used by its translation units. | The pinned clangd fork runs every registered command, keeps headers scoped by translation unit and configuration, and captures complete Clang roles, relations, macros, includes, diagnostics and source digests from the same compiler pass. | Versioned native shards cross one optimistic atomic snapshot; the client verifies producer identity, complete manifests, native and common shard digests, coverage, unresolved boundaries and source identities before publication. | No-op requests reuse the exact resident graph; changed sources or compilation-database commands reindex their owning translation units while a failed batch preserves the last complete generation without publishing it as current. | +| `scip-java` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Maven or Gradle project metadata, dependency/classpath state and Java/Kotlin compiler inputs. | scip-java drives the selected Maven or Gradle build and its Java/Kotlin producers as one batch. | The complete decoded artifact is merged as a contains/references graph before atomic publication. | Unchanged inputs reuse the validated snapshot; no javac, kotlinc or build session remains resident. | +| `scip-dotnet` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Solution/project/TFM/NuGet/MSBuild inputs and a resolvable SDK. | scip-dotnet loads and analyzes the selected solution through one batch producer run. | The complete decoded artifact publishes declarations but no currently defensible edge family. | Unchanged inputs reuse the validated snapshot; no Roslyn workspace remains resident. | +| `scip-python` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Python project/config/environment/import/stub inputs. | scip-python runs its bundled Pyright-based analysis once for the selected project environment. | The complete decoded artifact publishes a references-only project graph. | Unchanged inputs reuse the validated snapshot; no Pyright analysis session remains resident. | +| `scip-ruby` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Gem/Bundler/Sorbet/RBI configuration inputs. | scip-ruby performs one full-project batch using the selected Ruby, Bundler and Sorbet inputs. | The complete decoded artifact publishes declarations but no currently defensible edge family. | Unchanged inputs reuse the validated snapshot; no Ruby or Sorbet index remains resident. | +| `scip-dart` | `unchanged-snapshot-reuse; full-rebuild-on-change` | pubspec/lock, analysis options and resolved package configuration. | scip_dart performs one full-project batch using the resolved Dart package universe. | The complete decoded artifact publishes declarations but no currently defensible edge family. | Unchanged inputs reuse the validated snapshot; this is not resident Analysis Server state. | +| `scip-php` | `unchanged-snapshot-reuse; full-rebuild-on-change` | Composer manifest/lock/autoload and PHP/PHPStan configuration inputs. | The project-local scip-php producer performs one full-project batch through Composer/PHP inputs. | The complete decoded artifact publishes declarations but no currently defensible edge family. | Unchanged inputs reuse the validated snapshot; no PHPStan or Composer analysis session remains resident. | + +#### Installation and selection + +The troubleshooting table names the ordinary language-server/static fallback for each row. Resolution metadata is shared with the shipped registry and checked in CI. + +| Provider | Install | Install sources | Fixed commands | Project command sources | Overrides | Resolution order | Project preparation | Platforms | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `ttscgraph` | Install a ttsc release that supports graph snapshot protocol v1. `ttsc@0.23.0` provides the ordinary `ttscserver` fallback but predates this strict protocol. | [ttsc 0.23.0 legacy release](https://www.npmjs.com/package/ttsc/v/0.23.0), [native shard producer PR](https://github.com/samchon/ttsc/pull/1056) | `ttscgraph`, `ttscserver` | — | `TTSC_GRAPH_BINARY` | Absolute `TTSC_GRAPH_BINARY`, target-project `ttsc` package/binary, target-project `.bin`, then PATH/global compatibility fallback. | A matching ttsc/TypeScript project and tsconfig/jsconfig/package inputs. | `linux`, `macos`, `windows` | +| `samchon-graph-go` | Go 1.25+; the package ships the Go exporter source. Install corroboration with `go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7`. | [Go downloads](https://go.dev/dl/), [scip-go 0.2.7 source](https://github.com/scip-code/scip-go/tree/v0.2.7) | `samchon-graph-go`, `go`, `scip-go` | — | `SAMCHON_GRAPH_GO`, `SAMCHON_GRAPH_GO_TOOLCHAIN`, `SAMCHON_GRAPH_SCIP_GO` | Project/PATH `samchon-graph-go`, then the shipped source runner through Go; absolute environment overrides take precedence. | Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs. | `linux`, `macos`, `windows` | +| `samchon-graph-lua` | Install `lua-language-server`; the package ships `sidecars/lua/export.lua`. | [LuaLS releases](https://github.com/LuaLS/lua-language-server/releases) | `lua-language-server` | — | `SAMCHON_GRAPH_LUA`, `SAMCHON_GRAPH_LUA_EXPORTER` | Absolute `SAMCHON_GRAPH_LUA`, then project/PATH LuaLS; the shipped exporter may be replaced by `SAMCHON_GRAPH_LUA_EXPORTER`. | LuaLS workspace configuration and the shipped readable exporter. | `linux`, `macos`, `windows` | +| `samchon-rust-analyzer-hir` | Build the `samchon/rust-analyzer` graph-snapshot fork at commit `2850ecba80311bebd4cdaa9fedc5321533b5b1e7`; point `SAMCHON_GRAPH_RUST_ANALYZER_HIR` at that binary or install it as `samchon-rust-analyzer`. | [native HIR graph producer PR](https://github.com/samchon/rust-analyzer/pull/1), [rust-analyzer build instructions](https://rust-analyzer.github.io/book/contributing.html) | `samchon-rust-analyzer`, `rust-analyzer` | — | `SAMCHON_GRAPH_RUST_ANALYZER_HIR` | Absolute `SAMCHON_GRAPH_RUST_ANALYZER_HIR`, then project/PATH `samchon-rust-analyzer`, then a project/PATH `rust-analyzer` only when its version reports the pinned producer commit. | Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe. | `linux`, `macos`, `windows` | +| `clangd-snapshot` | Build the `samchon/llvm-project` graph-snapshot fork at commit `ae904413566b54aca08e46ebee1769c110601e6b`; point `SAMCHON_GRAPH_CLANGD_SNAPSHOT` at `clangd` or install it as `samchon-clangd`, and provide a compilation database. | [native Clang graph producer PR](https://github.com/samchon/llvm-project/pull/1), [LLVM build instructions](https://llvm.org/docs/CMake.html) | `samchon-clangd`, `clangd` | `compile_commands.json`, `build/compile_commands.json` | `SAMCHON_GRAPH_CLANGD_SNAPSHOT` | Absolute `SAMCHON_GRAPH_CLANGD_SNAPSHOT`, then project/PATH `samchon-clangd`, then a project/PATH `clangd` only when its version reports the pinned producer commit. | A valid compilation database plus every source, header, generated input, command, target and working-directory identity used by its translation units. | `linux`, `macos`, `windows` | +| `scip-java` | Install `scip-java` 0.13.1, the `scip` decoder and a compatible JDK; Kotlin experiments pin a compatible source build. | [scip-java 0.13.1 release](https://github.com/scip-code/scip-java/releases/tag/v0.13.1), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-java`, `scip`, `java` | — | `SAMCHON_GRAPH_SCIP_JAVA`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_JAVA_TOOLCHAIN` | Project-local producer/decoder/JDK precede PATH; absolute environment overrides select each tool. | Maven or Gradle project metadata, dependency/classpath state and Java/Kotlin compiler inputs. | `linux`, `macos`, `windows` | +| `scip-dotnet` | `dotnet tool install --global scip-dotnet`; install the `scip` decoder and matching .NET SDK. | [scip-dotnet on NuGet](https://www.nuget.org/packages/scip-dotnet), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-dotnet`, `scip`, `dotnet` | — | `SAMCHON_GRAPH_SCIP_DOTNET`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_DOTNET_TOOLCHAIN` | Project-local producer/decoder/toolchain precede PATH; absolute environment overrides select each tool. | Solution/project/TFM/NuGet/MSBuild inputs and a resolvable SDK. | `linux`, `macos`, `windows` | +| `scip-python` | `npm install -g @sourcegraph/scip-python@0.6.6`; install the `scip` decoder and select Python. | [scip-python 0.6.6 on npm](https://www.npmjs.com/package/@sourcegraph/scip-python/v/0.6.6), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-python`, `scip`, `python3`, `python`, `py` | — | `SAMCHON_GRAPH_SCIP_PYTHON`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_PYTHON_TOOLCHAIN` | Project-local producer/decoder/interpreter precede PATH; Python aliases are tried in order and absolute overrides select each tool. | Python project/config/environment/import/stub inputs. | `linux`, `macos`, `windows` | +| `scip-ruby` | Install the pinned `scip-ruby` 0.4.7 release binary, the `scip` decoder and matching Ruby/Bundler. | [scip-ruby 0.4.7 release](https://github.com/sourcegraph/scip-ruby/releases/tag/scip-ruby-v0.4.7), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-ruby`, `scip`, `ruby` | — | `SAMCHON_GRAPH_SCIP_RUBY`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_RUBY_TOOLCHAIN` | Project-local producer/decoder/Ruby precede PATH; absolute environment overrides select each tool. | Gem/Bundler/Sorbet/RBI configuration inputs. | `linux`, `macos`, `windows-when-installed` | +| `scip-dart` | `dart pub global activate scip_dart 1.6.2`; install the `scip` decoder and Dart SDK. | [scip_dart 1.6.2](https://pub.dev/packages/scip_dart/versions/1.6.2), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip_dart`, `scip`, `dart` | — | `SAMCHON_GRAPH_SCIP_DART`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_DART_TOOLCHAIN` | Project-local producer/decoder/Dart precede PATH; absolute environment overrides select each tool. | pubspec/lock, analysis options and resolved package configuration. | `linux`, `macos`, `windows` | +| `scip-php` | Install the project-local scip-php dependency with Composer, expose `vendor/bin/scip-php`, and install the `scip` decoder. | [scip-php source](https://github.com/davidrjenni/scip-php), [SCIP releases](https://github.com/sourcegraph/scip/releases) | `scip-php`, `scip`, `php` | — | `SAMCHON_GRAPH_SCIP_PHP`, `SAMCHON_GRAPH_SCIP`, `SAMCHON_GRAPH_PHP_TOOLCHAIN` | Project `vendor/bin` precedes PATH; absolute producer/decoder/PHP overrides select each tool. | Composer manifest/lock/autoload and PHP/PHPStan configuration inputs. | `linux`, `macos`, `windows` | + +#### Verified cold index cells + +These are exact same-run cold end-to-end strict/strict-disabled pairs from [`tests/benchmark/results/graph.json`](https://github.com/samchon/compiler-graph/blob/master/tests/benchmark/results/graph.json), produced by [the pinned workflow run](https://github.com/samchon/compiler-graph/actions/runs/30448033020). They do not prove warm or semantic-incremental behavior. A zero-fact strict provider is not called semantically complete. Ruby and Dart report only that both whole cells exceeded the 1,800-second guard; that limit is not an isolated producer duration. + +| Project | Strict provider | Strict cell | Strict-disabled cell | +| --- | --- | --- | --- | +| `excalidraw` | `ttscgraph` | 5,340.296 ms | 2,977.720 ms | +| `gin` | `samchon-graph-go` | 38,097.048 ms | 687.107 ms | +| `lualine` | `samchon-graph-lua` | 18,889.245 ms | 27,848.007 ms | +| `tokio` | `rust-analyzer-scip` (prior fallback evidence; `samchon-rust-analyzer-hir` not yet measured) | 55,238.180 ms | 229,860.996 ms | +| `redis` | `scip-clang` (prior fallback evidence; `clangd-snapshot` not yet measured) | 22,794.688 ms | 262,905.796 ms | +| `leveldb` | `scip-clang` (prior fallback evidence; `clangd-snapshot` not yet measured) | 8,352.928 ms | 26,451.952 ms | +| `gson` | `scip-java` | 88,653.499 ms | 231,398.489 ms | +| `koin` | `scip-java` | 211,263.800 ms | 967,711.761 ms | +| `serilog` | `scip-dotnet` | 20,498.324 ms | 25,085.071 ms | +| `flask` | `scip-python` | 10,628.897 ms | 748.454 ms | +| `sinatra` | `scip-ruby` | did not finish before 1,800 s | did not finish before 1,800 s | +| `darthttp` | `scip-dart` | did not finish before 1,800 s | did not finish before 1,800 s | +| `slim` | `scip-php` | 3,771.828 ms | 9,611.108 ms | + +#### Troubleshooting + +A strict result's provenance name must equal the provider below. If it is absent, use the commands and overrides in the installation table, then follow the explicit decline reason; the fallback is still usable but does not inherit strict authority. + +| Languages | Expected provenance | Common boundary | Common decline | Fallback | +| --- | --- | --- | --- | --- | +| `typescript` | `ttscgraph` | No compatible ttsc release is published yet. Version 0.23.0 returns a legacy complete dump and therefore falls back honestly until the native shard producer ships. | A missing target-project ttsc binary, legacy full-dump producer, incompatible request cap, malformed transaction or unsupported schema declines the strict provider. | `ttscserver`, then `@samchon/graph-sitter`. | +| `go` | `samchon-graph-go` | Current changed-input export is a full rebuild and does not retain a resident `go/packages` checker session. | A missing Go 1.25+ toolchain, missing pinned scip-go corroborator or invalid workspace/module load declines the strict provider. | `gopls`, then `@samchon/graph-sitter`. | +| `lua` | `samchon-graph-lua` | The current exporter calls `vm.getRefs` per declaration and proves references only; #83 replaces it with an occurrence-oriented resident traversal. | A missing LuaLS binary/exporter, invalid workspace result or bounded request declines the strict provider. | Generic LuaLS, then `@samchon/graph-sitter`. | +| `rust` | `samchon-rust-analyzer-hir` | The producer is currently available from the draft fork PR rather than a rust-analyzer release, and Rust has no `renders` relationship family. | A missing pinned producer, incompatible commit/schema, malformed transaction, invalid checkpoint, unsupported bounded option or failed Cargo workspace load declines this route. | Stock `rust-analyzer-scip`, then generic rust-analyzer, then `@samchon/graph-sitter`. | +| `c`, `cpp` | `clangd-snapshot` | Calls, instantiation, exports, implements and dispatch are explicitly partial; C/C++ have no decorates, renders or tests family, and the producer is currently a draft fork rather than an LLVM release. | A missing pinned producer, missing or invalid compilation database, incompatible schema/commit, incomplete indexing batch, source/configuration movement or compiler error declines this route. | `scip-clang`, then stock `clangd`, then `@samchon/graph-sitter`. | +| `java`, `kotlin` | `scip-java` | The released Gradle path disables configuration cache and runs `clean scipCompileAll`; it is navigation fallback for #74/#76, not compiler-owned calls or accesses. | A missing producer/decoder/JDK, unsupported Maven or Gradle project, or invalid dependency/build configuration declines the strict provider. | `jdtls` or `kotlin-language-server`, then `@samchon/graph-sitter`. | +| `csharp` | `scip-dotnet` | The artifact proves declarations but no graph edge family and can log MSBuildWorkspace failures after publishing. | A missing producer/decoder/.NET SDK, absent solution/project input or invalid MSBuild load declines the strict provider. | `csharp-ls`, then `@samchon/graph-sitter`. | +| `python` | `scip-python` | The bundled historical Pyright core proves references only and can recover from malformed pyproject configuration with defaults. | A missing producer/decoder/Python interpreter, absent project input or unusable Python environment declines the strict provider. | `pyright-langserver`, then `@samchon/graph-sitter`. | +| `ruby` | `scip-ruby` | The current artifact proves no graph edge family; it does not expose structural coverage, Sorbet sigils or typed unresolved sites. | A missing producer/decoder/Ruby runtime, unusable Bundler environment or invalid project configuration declines the strict provider. | `ruby-lsp`, then `@samchon/graph-sitter`. | +| `dart` | `scip-dart` | The current artifact proves no graph edge family and is not resident Analysis Server state. | A missing producer/decoder/Dart SDK, absent package configuration or failed pub resolution declines the strict provider. | Dart Analysis Server, then `@samchon/graph-sitter`. | +| `php` | `scip-php` | The current raw parser/Composer artifact proves no graph edge family and has no diagnostic or role grounding. | A missing project-local producer/decoder/PHP runtime, absent Composer autoload or invalid project configuration declines the strict provider. | `intelephense`, then `@samchon/graph-sitter`. | + +#### Ordinary-only strict status + +These languages are indexed through their ordinary server and static fallback today. They have no registered strict provider or strict timing claim. + +| Language | Ordinary server | Why no strict provider | Route | +| --- | --- | --- | --- | +| `scala` | `metals` | No registered strict provider; scip-java no longer supports Scala. | [tracked route](https://github.com/samchon/compiler-graph/issues/77) | +| `swift` | `sourcekit-lsp` | No packaged IndexStoreDB/SourceKit-LSP snapshot producer is registered. | [tracked route](https://github.com/samchon/compiler-graph/issues/78) | +| `zig` | `zls` | No analyzer or compiler Sema snapshot producer is registered. | [tracked route](https://github.com/samchon/compiler-graph/issues/79) | + + +### Repository topology + +The same `inspect_code_graph` tool has a `topology` request for workspaces, packages, source roots, targets, tasks, entrypoints, project dependencies, and file joins. These facts use a sibling provider plane: repository nodes never masquerade as code symbols, and a file join is returned only when the topology model can be fenced against one stable code generation. + +The first adapter slice is deliberately read-only: + +| Ecosystem | Model and policy | +|---|---| +| pnpm | Runs `pnpm list -r --json --depth 0` and reads versioned package/workspace/lock manifests. Package scripts are listed as tasks, never executed. | +| Cargo | Runs `cargo metadata --format-version 1 --locked --offline`; it neither updates the lockfile nor accesses the network. | +| Gradle | Uses the Tooling API and may evaluate project configuration, but runs no task. It is disabled until `SAMCHON_GRAPH_ALLOW_GRADLE_MODEL=1`; provide `SAMCHON_GRAPH_GRADLE_TOOLING_CLASSPATH` or `GRADLE_HOME`. | +| CMake | Reads existing File API codemodel-v2 and cmakeFiles-v1 replies. It never writes a query or configures the project; set `SAMCHON_GRAPH_CMAKE_REPLY` when the reply is outside a conventional build directory. | + +Every topology result carries provider/tool provenance, per-relation `complete`/`partial`/`unsupported` coverage, its resident generation, and explicit join compatibility. Gradle configuration failures and missing or stale CMake replies become unavailable coverage; they do not fall back to guessed build facts. -TypeScript's provider is the compiler-owned `ttscgraph` snapshot. The binary is resolved from the target project's `ttsc` installation; `TTSC_GRAPH_BINARY` can point to an exact absolute binary for development or release verification. If the binary is unavailable, its schema/provenance cannot be trusted, or the requested build is deliberately capped, indexing states the reason and falls back to `ttscserver`, then to the static indexer when no server is available. `ttscgraph` schema 6 is the complete portable contract: paths are relative to the producer's project (including `../` siblings), virtual libraries use `bundled:///`, and declarations may carry compiler-bounded signatures. Older producers are refused and indexing falls back honestly to `ttscserver`. +Before the generic lane runs, indexing asks a registry of strict providers which languages they own. A provider states what its facts are grounded in — a compiler, a whole-project analyzer, or a precomputed semantic index — and which edge families it can prove; a snapshot that publishes outside those is rejected rather than merged. Whatever no provider claims falls through to the language server, and then to the static indexer. Every decline is one sentence naming the provider and the authority the build gave up, so a fallback is never mistaken for the strict result it replaced. -Go's compiler-owned provider is shipped with this package and runs through Go 1.25 or newer. Its navigation corroboration is pinned to `scip-go` 0.2.7; install that exact producer with `go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7`. A project-local or `PATH` `samchon-graph-go` binary takes precedence over the bundled source runner, `SAMCHON_GRAPH_GO` can select an absolute development build, and `SAMCHON_GRAPH_SCIP_GO` can select an absolute `scip-go` binary. Without the required Go toolchain or pinned indexer, indexing reports the strict-provider decline and retains the generic `gopls` fallback. +The dump carries one `provenance` row per contributing provider: its authority, the fact families it proves, the producing tool and versions, a fingerprint of the inputs that decided the file set, and digests over the manifest and the published facts. It is absent when no strict provider served the build. What a provider *did* to compute a generation is deliberately not recorded there because that belongs to one refresh rather than to the facts. JavaScript is intentionally not indexed. In an arbitrary repository, `.js`/`.jsx`/`.mjs`/`.cjs` files are as often build output or vendored bundles as handwritten source, and the graph cannot tell which without project-specific provenance. @@ -119,27 +238,7 @@ checkout-grounding rule used by the reference harness. ### Indexing time -| Project | Language | First index | -|---|---|---| -| [slim](https://github.com/slimphp/Slim) | PHP | 5s | -| [excalidraw](https://github.com/excalidraw/excalidraw) | TypeScript | 5s | -| [gin](https://github.com/gin-gonic/gin) | Go | 15s | -| [leveldb](https://github.com/google/leveldb) | C++ | 16s | -| [darthttp](https://github.com/dart-lang/http) | Dart | 23s | -| [lualine](https://github.com/nvim-lualine/lualine.nvim) | Lua | 25s | -| [flask](https://github.com/pallets/flask) | Python | 55s | -| [gson](https://github.com/google/gson) | Java | 2m22s | -| [sinatra](https://github.com/sinatra/sinatra) | Ruby | 2m35s | -| [redis](https://github.com/redis/redis) | C | 2m50s | -| [tokio](https://github.com/tokio-rs/tokio) | Rust | 3m | -| [koin](https://github.com/InsertKoinIO/koin) | Kotlin | 19m25s | -| [serilog](https://github.com/serilog/serilog) | C# | not recorded | - -One-time cost per repository. The server re-scans only changed files after that (see [How it works](#how-it-works)); later calls are free. - -kotlin-language-server, jdtls, and csharp-ls are particularly slow: each resolves the whole project before answering anything. - -TypeScript and Go already close that gap through compiler-owned snapshots. The remaining languages use their listed language servers until their compiler-owned bulk providers land. +The exact current same-run cold strict/strict-disabled measurements are generated from the pinned result artifact in [Verified cold index cells](#verified-cold-index-cells). They make no warm, resident, or semantic-completeness claim; lifecycle modes are listed separately from measured cold time. ### Reproduction @@ -199,6 +298,8 @@ pnpm --filter @samchon/graph-benchmark render:png # reference SVG + exact 2x PN * the classes that implement an interface, which is the one call that answers * "what actually implements this". * - `overview`: project layers and folder structure. + * - `topology`: workspace, package, target, task, source-root, entrypoint, and + * project-dependency orientation from declared or owning-tool models. * - `escape`: the answer is outside the graph (source body text, files outside * the indexed languages, exact search). * @@ -247,25 +348,24 @@ pnpm --filter @samchon/graph-benchmark render:png # reference SVG + exact 2x PN */ export interface ISamchonGraphApplication { /** - * Answer a __LANG__ question from this repository's own program index. + * Answer a __LANG__ question from the repository's program index. * - * The graph holds every symbol, call, type, decorator and test, each with its - * file and line, resolved from the source on disk now. Submit exactly one + * The graph returns proved facts with coverage and uncertainty. Submit one * request: * - * - `tour`: architecture, the runtime flow from the public API to the code that - * does the work, nearby paths, and the tests to read — a whole orientation - * in one call + * - `tour`: architecture, runtime flow, nearby paths, and tests * - `trace`: what a symbol calls, what calls it, or the path from A to B * - `details`: signatures, members, and what implements an interface * - `lookup`: where a named symbol is declared * - `entrypoints`: where execution starts, when the entry is unknown * - `overview`: the project's layers and folder structure + * - `topology`: repository workspaces, packages, roots, targets, tasks, and + * dependencies * * Every fact in a result is checked against the index before return, so no * fact needs verifying; for the ranked operations (`lookup`, `entrypoints`, - * `tour`), judge whether the shortlist covers your question. Read a file for - * what the graph does not carry: a body or the text inside a span. + * `tour`), judge whether the shortlist covers your question. Read source only + * for a body or span text. * * @param props Reasoning plus one graph request * @returns Matching `result` union member @@ -304,6 +404,7 @@ export namespace ISamchonGraphApplication { | ISamchonGraphDetails.IRequest | ISamchonGraphOverview.IRequest | ISamchonGraphTour.IRequest + | ISamchonGraphTopology.IRequest | ISamchonGraphEscape.IRequest; } @@ -335,6 +436,27 @@ export namespace ISamchonGraphApplication { */ audit: string; + /** + * Strict producer, authority, compiler and build-universe identity for the + * synchronized graph. Absent for `escape`, for `topology` whose facts come + * from the repository plane and carry their own provenance, and for a + * legacy or fallback-only dump with no strict producer. + */ + provenance?: ISamchonGraphDump.IProvenance[]; + + /** + * Machine-readable completeness for the relationship families relevant to + * this operation. Absent for `escape` and for `topology`, which reports + * its own relation coverage inside the result. + */ + coverage?: ISamchonGraphCoverageSummary; + + /** + * Bounded structured uncertainty for the same operation-scoped families. + * Absent for `escape` and for `topology`, whose plane publishes none. + */ + unresolved?: ISamchonGraphUnresolvedSummary; + /** What to do with `result`: answer, inspect one named request, or escape. */ next: ISamchonGraphNext; @@ -346,6 +468,7 @@ export namespace ISamchonGraphApplication { | ISamchonGraphDetails | ISamchonGraphOverview | ISamchonGraphTour + | ISamchonGraphTopology | ISamchonGraphEscape; } } diff --git a/docs/provider-support.json b/docs/provider-support.json new file mode 100644 index 00000000..b200e2fb --- /dev/null +++ b/docs/provider-support.json @@ -0,0 +1,369 @@ +{ + "schemaVersion": 1, + "benchmark": { + "artifact": "tests/benchmark/results/graph.json", + "workflowRun": "https://github.com/samchon/compiler-graph/actions/runs/30448033020", + "kind": "cold end-to-end strict versus strict-disabled pairs" + }, + "providers": [ + { + "provider": "ttscgraph", + "languages": ["typescript"], + "status": "registered", + "authority": "compiler", + "facts": ["exports", "calls", "accesses", "instantiates", "type_ref", "extends", "implements", "overrides", "renders"], + "commands": ["ttscgraph", "ttscserver"], + "environmentOverrides": ["TTSC_GRAPH_BINARY"], + "install": "Install a ttsc release that supports graph snapshot protocol v1. `ttsc@0.23.0` provides the ordinary `ttscserver` fallback but predates this strict protocol.", + "installSources": [ + {"label": "ttsc 0.23.0 legacy release", "url": "https://www.npmjs.com/package/ttsc/v/0.23.0"}, + {"label": "native shard producer PR", "url": "https://github.com/samchon/ttsc/pull/1056"} + ], + "resolution": "Absolute `TTSC_GRAPH_BINARY`, target-project `ttsc` package/binary, target-project `.bin`, then PATH/global compatibility fallback.", + "requirements": "A matching ttsc/TypeScript project and tsconfig/jsconfig/package inputs.", + "platforms": ["linux", "macos", "windows"], + "mode": "resident-no-op-reuse; invalidated-closure shard deltas with a compatible producer", + "nativeAnalysis": "A compatible target-project ttsc checker owns one resident compiler process and its incremental semantic state.", + "exportMerge": "Changed compiler-owned raw shards cross a versioned transaction; the client validates the complete manifest and adapts only upserts before atomic publication.", + "reuseResident": "Unchanged requests reuse the exact snapshot; body edits reuse unaffected native and normalized shards, while build-universe changes reload safely.", + "limitations": "No compatible ttsc release is published yet. Version 0.23.0 returns a legacy complete dump and therefore falls back honestly until the native shard producer ships.", + "decline": "A missing target-project ttsc binary, legacy full-dump producer, incompatible request cap, malformed transaction or unsupported schema declines the strict provider.", + "fallback": "`ttscserver`, then `@samchon/graph-sitter`.", + "experimentLanguages": ["typescript"], + "experimentTool": "ttscgraph", + "experimentCapabilities": ["universe", "sourceDigests", "diskDigests", "diagnostics"], + "benchmarks": [{"project": "excalidraw", "strictMs": 5340.295836, "fallbackMs": 2977.720236}], + "upstream": "https://github.com/samchon/ttsc", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/63"] + }, + { + "provider": "samchon-graph-go", + "languages": ["go"], + "status": "registered", + "authority": "compiler", + "facts": ["contains", "exports", "imports", "calls", "accesses", "instantiates", "type_ref", "implements", "dispatches", "tests", "references"], + "commands": ["samchon-graph-go", "go", "scip-go"], + "environmentOverrides": ["SAMCHON_GRAPH_GO", "SAMCHON_GRAPH_GO_TOOLCHAIN", "SAMCHON_GRAPH_SCIP_GO"], + "install": "Go 1.25+; the package ships the Go exporter source. Install corroboration with `go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7`.", + "installSources": [ + {"label": "Go downloads", "url": "https://go.dev/dl/"}, + {"label": "scip-go 0.2.7 source", "url": "https://github.com/scip-code/scip-go/tree/v0.2.7"} + ], + "resolution": "Project/PATH `samchon-graph-go`, then the shipped source runner through Go; absolute environment overrides take precedence.", + "requirements": "Go workspace/module inputs, selected GOOS/GOARCH/cgo environment, embedded files and vendored inputs.", + "platforms": ["linux", "macos", "windows"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "The shipped exporter runs one compiler-owned go/packages batch against the selected build universe.", + "exportMerge": "A changed-input batch emits and validates one whole-workspace graph before snapshot publication.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; no resident go/packages checker survives changed builds.", + "limitations": "Current changed-input export is a full rebuild and does not retain a resident `go/packages` checker session.", + "decline": "A missing Go 1.25+ toolchain, missing pinned scip-go corroborator or invalid workspace/module load declines the strict provider.", + "fallback": "`gopls`, then `@samchon/graph-sitter`.", + "experimentLanguages": ["go"], + "experimentTool": "samchon-graph-go", + "experimentCapabilities": ["universe", "sourceDigests", "fullRebuild"], + "benchmarks": [{"project": "gin", "strictMs": 38097.048393, "fallbackMs": 687.107355}], + "upstream": "https://github.com/scip-code/scip-go", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/63"] + }, + { + "provider": "samchon-graph-lua", + "languages": ["lua"], + "status": "registered", + "authority": "analyzer", + "facts": ["references"], + "commands": ["lua-language-server"], + "environmentOverrides": ["SAMCHON_GRAPH_LUA", "SAMCHON_GRAPH_LUA_EXPORTER"], + "install": "Install `lua-language-server`; the package ships `sidecars/lua/export.lua`.", + "installSources": [{"label": "LuaLS releases", "url": "https://github.com/LuaLS/lua-language-server/releases"}], + "resolution": "Absolute `SAMCHON_GRAPH_LUA`, then project/PATH LuaLS; the shipped exporter may be replaced by `SAMCHON_GRAPH_LUA_EXPORTER`.", + "requirements": "LuaLS workspace configuration and the shipped readable exporter.", + "platforms": ["linux", "macos", "windows"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "LuaLS analyzes the workspace and the shipped exporter asks its semantic VM for declaration references.", + "exportMerge": "A changed-input run publishes one references-only whole-workspace graph.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; the current exporter is not a resident incremental session.", + "limitations": "The current exporter calls `vm.getRefs` per declaration and proves references only; #83 replaces it with an occurrence-oriented resident traversal.", + "decline": "A missing LuaLS binary/exporter, invalid workspace result or bounded request declines the strict provider.", + "fallback": "Generic LuaLS, then `@samchon/graph-sitter`.", + "experimentLanguages": ["lua"], + "experimentTool": "lua-language-server", + "experimentCapabilities": ["universe", "diskDigests"], + "benchmarks": [{"project": "lualine", "strictMs": 18889.245252, "fallbackMs": 27848.006717}], + "upstream": "https://github.com/LuaLS/lua-language-server", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/83"] + }, + { + "provider": "samchon-rust-analyzer-hir", + "languages": ["rust"], + "status": "registered", + "authority": "analyzer", + "facts": ["contains", "exports", "imports", "calls", "accesses", "instantiates", "type_ref", "extends", "implements", "overrides", "dispatches", "decorates", "tests", "references"], + "commands": ["samchon-rust-analyzer", "rust-analyzer"], + "environmentOverrides": ["SAMCHON_GRAPH_RUST_ANALYZER_HIR"], + "install": "Build the `samchon/rust-analyzer` graph-snapshot fork at commit `2850ecba80311bebd4cdaa9fedc5321533b5b1e7`; point `SAMCHON_GRAPH_RUST_ANALYZER_HIR` at that binary or install it as `samchon-rust-analyzer`.", + "installSources": [ + {"label": "native HIR graph producer PR", "url": "https://github.com/samchon/rust-analyzer/pull/1"}, + {"label": "rust-analyzer build instructions", "url": "https://rust-analyzer.github.io/book/contributing.html"} + ], + "resolution": "Absolute `SAMCHON_GRAPH_RUST_ANALYZER_HIR`, then project/PATH `samchon-rust-analyzer`, then a project/PATH `rust-analyzer` only when its version reports the pinned producer commit.", + "requirements": "Cargo metadata/config, lock/toolchain inputs, target/features/cfg and build-script/proc-macro universe.", + "platforms": ["linux", "macos", "windows"], + "mode": "resident-no-op-reuse; invalidated-closure shard deltas; validated restart checkpoints", + "nativeAnalysis": "The pinned rust-analyzer fork owns one resident HIR database and exports declarations, semantic relationships, diagnostics, coverage and unresolved boundaries from that exact analysis revision.", + "exportMerge": "Content-addressed source shards cross a versioned LSP transaction; the client verifies producer identity, universe, complete manifests, shard digests and graph invariants before atomic publication.", + "reuseResident": "No-op requests reuse the resident snapshot; interface changes invalidate dependent shards, while a complete consumer checkpoint restores the same generation after process restart.", + "limitations": "The producer is currently available from the draft fork PR rather than a rust-analyzer release, and Rust has no `renders` relationship family.", + "decline": "A missing pinned producer, incompatible commit/schema, malformed transaction, invalid checkpoint, unsupported bounded option or failed Cargo workspace load declines this route.", + "fallback": "Stock `rust-analyzer-scip`, then generic rust-analyzer, then `@samchon/graph-sitter`.", + "experimentLanguages": ["rust"], + "experimentTool": "samchon-rust-analyzer", + "experimentCapabilities": ["coverage", "diagnostics", "diskDigests", "incremental", "sourceDigests", "universe", "unresolved", "validatedConsumerCheckpoint"], + "benchmarkProvider": "rust-analyzer-scip", + "benchmarks": [{"project": "tokio", "strictMs": 55238.18003, "fallbackMs": 229860.9964}], + "upstream": "https://github.com/samchon/rust-analyzer", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/72"] + }, + { + "provider": "clangd-snapshot", + "languages": ["c", "cpp"], + "status": "registered", + "authority": "compiler", + "facts": ["contains", "exports", "imports", "calls", "accesses", "instantiates", "type_ref", "extends", "implements", "overrides", "dispatches", "references"], + "commands": ["samchon-clangd", "clangd"], + "projectCommandSources": ["compile_commands.json", "build/compile_commands.json"], + "environmentOverrides": ["SAMCHON_GRAPH_CLANGD_SNAPSHOT"], + "install": "Build the `samchon/llvm-project` graph-snapshot fork at commit `ae904413566b54aca08e46ebee1769c110601e6b`; point `SAMCHON_GRAPH_CLANGD_SNAPSHOT` at `clangd` or install it as `samchon-clangd`, and provide a compilation database.", + "installSources": [ + {"label": "native Clang graph producer PR", "url": "https://github.com/samchon/llvm-project/pull/1"}, + {"label": "LLVM build instructions", "url": "https://llvm.org/docs/CMake.html"} + ], + "resolution": "Absolute `SAMCHON_GRAPH_CLANGD_SNAPSHOT`, then project/PATH `samchon-clangd`, then a project/PATH `clangd` only when its version reports the pinned producer commit.", + "requirements": "A valid compilation database plus every source, header, generated input, command, target and working-directory identity used by its translation units.", + "platforms": ["linux", "macos", "windows"], + "mode": "resident-no-op-reuse; complete changed-TU/configuration replacement; content-addressed deltas", + "nativeAnalysis": "The pinned clangd fork runs every registered command, keeps headers scoped by translation unit and configuration, and captures complete Clang roles, relations, macros, includes, diagnostics and source digests from the same compiler pass.", + "exportMerge": "Versioned native shards cross one optimistic atomic snapshot; the client verifies producer identity, complete manifests, native and common shard digests, coverage, unresolved boundaries and source identities before publication.", + "reuseResident": "No-op requests reuse the exact resident graph; changed sources or compilation-database commands reindex their owning translation units while a failed batch preserves the last complete generation without publishing it as current.", + "limitations": "Calls, instantiation, exports, implements and dispatch are explicitly partial; C/C++ have no decorates, renders or tests family, and the producer is currently a draft fork rather than an LLVM release.", + "decline": "A missing pinned producer, missing or invalid compilation database, incompatible schema/commit, incomplete indexing batch, source/configuration movement or compiler error declines this route.", + "fallback": "`scip-clang`, then stock `clangd`, then `@samchon/graph-sitter`.", + "experimentLanguages": ["c", "cpp"], + "experimentTool": "samchon-clangd", + "experimentCapabilities": ["coverage", "diagnostics", "diskDigests", "incremental", "sourceDigests", "universe", "unresolved"], + "benchmarkProvider": "scip-clang", + "benchmarks": [ + {"project": "redis", "strictMs": 22794.688115, "fallbackMs": 262905.79583}, + {"project": "leveldb", "strictMs": 8352.928418, "fallbackMs": 26451.951757} + ], + "upstream": "https://github.com/samchon/llvm-project", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/73"] + }, + { + "provider": "scip-java", + "languages": ["java", "kotlin"], + "status": "registered", + "authority": "semantic-index", + "facts": ["contains", "references"], + "commands": ["scip-java", "scip", "java"], + "environmentOverrides": ["SAMCHON_GRAPH_SCIP_JAVA", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_JAVA_TOOLCHAIN"], + "install": "Install `scip-java` 0.13.1, the `scip` decoder and a compatible JDK; Kotlin experiments pin a compatible source build.", + "installSources": [ + {"label": "scip-java 0.13.1 release", "url": "https://github.com/scip-code/scip-java/releases/tag/v0.13.1"}, + {"label": "SCIP releases", "url": "https://github.com/sourcegraph/scip/releases"} + ], + "resolution": "Project-local producer/decoder/JDK precede PATH; absolute environment overrides select each tool.", + "requirements": "Maven or Gradle project metadata, dependency/classpath state and Java/Kotlin compiler inputs.", + "platforms": ["linux", "macos", "windows"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "scip-java drives the selected Maven or Gradle build and its Java/Kotlin producers as one batch.", + "exportMerge": "The complete decoded artifact is merged as a contains/references graph before atomic publication.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; no javac, kotlinc or build session remains resident.", + "limitations": "The released Gradle path disables configuration cache and runs `clean scipCompileAll`; it is navigation fallback for #74/#76, not compiler-owned calls or accesses.", + "decline": "A missing producer/decoder/JDK, unsupported Maven or Gradle project, or invalid dependency/build configuration declines the strict provider.", + "fallback": "`jdtls` or `kotlin-language-server`, then `@samchon/graph-sitter`.", + "experimentLanguages": ["java", "kotlin"], + "experimentTool": "scip-java", + "experimentCapabilities": ["universe", "diskDigests"], + "benchmarks": [ + {"project": "gson", "strictMs": 88653.49921, "fallbackMs": 231398.488953}, + {"project": "koin", "strictMs": 211263.800455, "fallbackMs": 967711.761431} + ], + "upstream": "https://github.com/scip-code/scip-java", + "childIssues": [ + "https://github.com/samchon/compiler-graph/issues/74", + "https://github.com/samchon/compiler-graph/issues/76" + ] + }, + { + "provider": "scip-dotnet", + "languages": ["csharp"], + "status": "registered", + "authority": "semantic-index", + "facts": [], + "commands": ["scip-dotnet", "scip", "dotnet"], + "environmentOverrides": ["SAMCHON_GRAPH_SCIP_DOTNET", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_DOTNET_TOOLCHAIN"], + "install": "`dotnet tool install --global scip-dotnet`; install the `scip` decoder and matching .NET SDK.", + "installSources": [ + {"label": "scip-dotnet on NuGet", "url": "https://www.nuget.org/packages/scip-dotnet"}, + {"label": "SCIP releases", "url": "https://github.com/sourcegraph/scip/releases"} + ], + "resolution": "Project-local producer/decoder/toolchain precede PATH; absolute environment overrides select each tool.", + "requirements": "Solution/project/TFM/NuGet/MSBuild inputs and a resolvable SDK.", + "platforms": ["linux", "macos", "windows"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "scip-dotnet loads and analyzes the selected solution through one batch producer run.", + "exportMerge": "The complete decoded artifact publishes declarations but no currently defensible edge family.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; no Roslyn workspace remains resident.", + "limitations": "The artifact proves declarations but no graph edge family and can log MSBuildWorkspace failures after publishing.", + "decline": "A missing producer/decoder/.NET SDK, absent solution/project input or invalid MSBuild load declines the strict provider.", + "fallback": "`csharp-ls`, then `@samchon/graph-sitter`.", + "experimentLanguages": ["csharp"], + "experimentTool": "scip-dotnet", + "experimentCapabilities": ["universe", "diskDigests"], + "benchmarks": [{"project": "serilog", "strictMs": 20498.323945, "fallbackMs": 25085.071148}], + "upstream": "https://github.com/sourcegraph/scip-dotnet", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/75"] + }, + { + "provider": "scip-python", + "languages": ["python"], + "status": "registered", + "authority": "semantic-index", + "facts": ["references"], + "commands": ["scip-python", "scip", "python3", "python", "py"], + "environmentOverrides": ["SAMCHON_GRAPH_SCIP_PYTHON", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_PYTHON_TOOLCHAIN"], + "install": "`npm install -g @sourcegraph/scip-python@0.6.6`; install the `scip` decoder and select Python.", + "installSources": [ + {"label": "scip-python 0.6.6 on npm", "url": "https://www.npmjs.com/package/@sourcegraph/scip-python/v/0.6.6"}, + {"label": "SCIP releases", "url": "https://github.com/sourcegraph/scip/releases"} + ], + "resolution": "Project-local producer/decoder/interpreter precede PATH; Python aliases are tried in order and absolute overrides select each tool.", + "requirements": "Python project/config/environment/import/stub inputs.", + "platforms": ["linux", "macos", "windows"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "scip-python runs its bundled Pyright-based analysis once for the selected project environment.", + "exportMerge": "The complete decoded artifact publishes a references-only project graph.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; no Pyright analysis session remains resident.", + "limitations": "The bundled historical Pyright core proves references only and can recover from malformed pyproject configuration with defaults.", + "decline": "A missing producer/decoder/Python interpreter, absent project input or unusable Python environment declines the strict provider.", + "fallback": "`pyright-langserver`, then `@samchon/graph-sitter`.", + "experimentLanguages": ["python"], + "experimentTool": "scip-python", + "experimentCapabilities": ["universe", "diskDigests"], + "benchmarks": [{"project": "flask", "strictMs": 10628.897103, "fallbackMs": 748.453891}], + "upstream": "https://github.com/sourcegraph/scip-python", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/80"] + }, + { + "provider": "scip-ruby", + "languages": ["ruby"], + "status": "registered", + "authority": "semantic-index", + "facts": [], + "commands": ["scip-ruby", "scip", "ruby"], + "environmentOverrides": ["SAMCHON_GRAPH_SCIP_RUBY", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_RUBY_TOOLCHAIN"], + "install": "Install the pinned `scip-ruby` 0.4.7 release binary, the `scip` decoder and matching Ruby/Bundler.", + "installSources": [ + {"label": "scip-ruby 0.4.7 release", "url": "https://github.com/sourcegraph/scip-ruby/releases/tag/scip-ruby-v0.4.7"}, + {"label": "SCIP releases", "url": "https://github.com/sourcegraph/scip/releases"} + ], + "resolution": "Project-local producer/decoder/Ruby precede PATH; absolute environment overrides select each tool.", + "requirements": "Gem/Bundler/Sorbet/RBI configuration inputs.", + "platforms": ["linux", "macos", "windows-when-installed"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "scip-ruby performs one full-project batch using the selected Ruby, Bundler and Sorbet inputs.", + "exportMerge": "The complete decoded artifact publishes declarations but no currently defensible edge family.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; no Ruby or Sorbet index remains resident.", + "limitations": "The current artifact proves no graph edge family; it does not expose structural coverage, Sorbet sigils or typed unresolved sites.", + "decline": "A missing producer/decoder/Ruby runtime, unusable Bundler environment or invalid project configuration declines the strict provider.", + "fallback": "`ruby-lsp`, then `@samchon/graph-sitter`.", + "experimentLanguages": ["ruby"], + "experimentTool": "scip-ruby", + "experimentCapabilities": ["universe", "diskDigests"], + "benchmarks": [{"project": "sinatra", "strictTimedOutMs": 1800000, "fallbackTimedOutMs": 1800000}], + "upstream": "https://github.com/sourcegraph/scip-ruby", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/81"] + }, + { + "provider": "scip-dart", + "languages": ["dart"], + "status": "registered", + "authority": "semantic-index", + "facts": [], + "commands": ["scip_dart", "scip", "dart"], + "environmentOverrides": ["SAMCHON_GRAPH_SCIP_DART", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_DART_TOOLCHAIN"], + "install": "`dart pub global activate scip_dart 1.6.2`; install the `scip` decoder and Dart SDK.", + "installSources": [ + {"label": "scip_dart 1.6.2", "url": "https://pub.dev/packages/scip_dart/versions/1.6.2"}, + {"label": "SCIP releases", "url": "https://github.com/sourcegraph/scip/releases"} + ], + "resolution": "Project-local producer/decoder/Dart precede PATH; absolute environment overrides select each tool.", + "requirements": "pubspec/lock, analysis options and resolved package configuration.", + "platforms": ["linux", "macos", "windows"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "scip_dart performs one full-project batch using the resolved Dart package universe.", + "exportMerge": "The complete decoded artifact publishes declarations but no currently defensible edge family.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; this is not resident Analysis Server state.", + "limitations": "The current artifact proves no graph edge family and is not resident Analysis Server state.", + "decline": "A missing producer/decoder/Dart SDK, absent package configuration or failed pub resolution declines the strict provider.", + "fallback": "Dart Analysis Server, then `@samchon/graph-sitter`.", + "experimentLanguages": ["dart"], + "experimentTool": "scip-dart", + "experimentCapabilities": ["universe", "diskDigests"], + "benchmarks": [{"project": "darthttp", "strictTimedOutMs": 1800000, "fallbackTimedOutMs": 1800000}], + "upstream": "https://pub.dev/packages/scip_dart", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/84"] + }, + { + "provider": "scip-php", + "languages": ["php"], + "status": "registered", + "authority": "semantic-index", + "facts": [], + "commands": ["scip-php", "scip", "php"], + "environmentOverrides": ["SAMCHON_GRAPH_SCIP_PHP", "SAMCHON_GRAPH_SCIP", "SAMCHON_GRAPH_PHP_TOOLCHAIN"], + "install": "Install the project-local scip-php dependency with Composer, expose `vendor/bin/scip-php`, and install the `scip` decoder.", + "installSources": [ + {"label": "scip-php source", "url": "https://github.com/davidrjenni/scip-php"}, + {"label": "SCIP releases", "url": "https://github.com/sourcegraph/scip/releases"} + ], + "resolution": "Project `vendor/bin` precedes PATH; absolute producer/decoder/PHP overrides select each tool.", + "requirements": "Composer manifest/lock/autoload and PHP/PHPStan configuration inputs.", + "platforms": ["linux", "macos", "windows"], + "mode": "unchanged-snapshot-reuse; full-rebuild-on-change", + "nativeAnalysis": "The project-local scip-php producer performs one full-project batch through Composer/PHP inputs.", + "exportMerge": "The complete decoded artifact publishes declarations but no currently defensible edge family.", + "reuseResident": "Unchanged inputs reuse the validated snapshot; no PHPStan or Composer analysis session remains resident.", + "limitations": "The current raw parser/Composer artifact proves no graph edge family and has no diagnostic or role grounding.", + "decline": "A missing project-local producer/decoder/PHP runtime, absent Composer autoload or invalid project configuration declines the strict provider.", + "fallback": "`intelephense`, then `@samchon/graph-sitter`.", + "experimentLanguages": ["php"], + "experimentTool": "scip-php", + "experimentCapabilities": ["universe", "diskDigests"], + "benchmarks": [{"project": "slim", "strictMs": 3771.828111, "fallbackMs": 9611.108084}], + "upstream": "https://github.com/davidrjenni/scip-php", + "childIssues": ["https://github.com/samchon/compiler-graph/issues/82"] + } + ], + "ordinaryOnly": [ + { + "language": "scala", + "server": "metals", + "issue": "https://github.com/samchon/compiler-graph/issues/77", + "reason": "No registered strict provider; scip-java no longer supports Scala." + }, + { + "language": "swift", + "server": "sourcekit-lsp", + "issue": "https://github.com/samchon/compiler-graph/issues/78", + "reason": "No packaged IndexStoreDB/SourceKit-LSP snapshot producer is registered." + }, + { + "language": "zig", + "server": "zls", + "issue": "https://github.com/samchon/compiler-graph/issues/79", + "reason": "No analyzer or compiler Sema snapshot producer is registered." + } + ] +} diff --git a/package.json b/package.json index d363dfb3..68e4163e 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "test": "pnpm --filter @samchon/graph-test start", "coverage": "pnpm --filter @samchon/graph build && pnpm --filter @samchon/graph-test build && pnpm exec c8 --all --src packages/graph/src --src packages/graph-sitter/src --exclude \"tests/**\" --exclude \"packages/graph/src/view.ts\" --exclude \"packages/graph/src/viewer/**\" --exclude-after-remap --reporter=text --reporter=lcov --check-coverage --lines 100 --functions 100 --branches 100 node tests/test-graph/lib/index.mjs", "parity": "pnpm --filter @samchon/graph-test build && node tests/test-graph/lib/parity.mjs", + "provider-support": "node packages/graph/build/provider-support.mjs --check", + "provider-support:write": "node packages/graph/build/provider-support.mjs --write", "experiment": "pnpm --filter @samchon/graph-experiment start", "benchmark": "pnpm --filter @samchon/graph-benchmark start", "release": "bumpp --r" diff --git a/packages/graph/build/copy-sidecars.mjs b/packages/graph/build/copy-sidecars.mjs index 23deaaff..e3db1e6b 100644 --- a/packages/graph/build/copy-sidecars.mjs +++ b/packages/graph/build/copy-sidecars.mjs @@ -5,10 +5,10 @@ import { fileURLToPath } from "node:url"; /** * Copy the sidecar sources this package ships into the package itself. * - * Two quite different things travel this way. The Go sidecar is source a user - * compiles into `samchon-graph-go`; the Lua exporter is a script the provider - * hands to lua-language-server at run time, so it has to be present in an - * installed package rather than only in this repository. + * The Go sidecar is source a user compiles into `samchon-graph-go`; the Gradle + * Java source reads the opted-in Tooling API model; and the Lua exporter is a + * script the provider hands to lua-language-server at run time. All three must + * exist in an installed package rather than only in this repository. * * Named per file rather than copied wholesale. A directory copy would ship * whatever happened to be sitting there — a probe, a scratch file, a build @@ -21,6 +21,7 @@ const packageRoot = path.resolve( const repositoryRoot = path.resolve(packageRoot, "..", ".."); const SIDECARS = { + gradle: ["RepositoryContext.java"], go: [ "analyze.go", "go.mod", diff --git a/packages/graph/build/provider-support.mjs b/packages/graph/build/provider-support.mjs new file mode 100644 index 00000000..8af2d3a9 --- /dev/null +++ b/packages/graph/build/provider-support.mjs @@ -0,0 +1,646 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../..", +); +const startMarker = ""; +const endMarker = ""; +const args = new Set(process.argv.slice(2)); +const manifestArgument = process.argv + .slice(2) + .find((argument) => argument.startsWith("--manifest=")); +const manifestFile = path.resolve( + root, + manifestArgument?.slice("--manifest=".length) ?? + "docs/provider-support.json", +); +const readmeArgument = process.argv + .slice(2) + .find((argument) => argument.startsWith("--readme=")); +const readmeFile = path.resolve( + root, + readmeArgument?.slice("--readme=".length) ?? "README.md", +); +const write = args.has("--write"); +const validateOnly = args.has("--validate-only"); +const supportedPlatforms = new Set([ + "linux", + "macos", + "windows", + "windows-when-installed", +]); + +if (write && validateOnly) { + throw new Error( + "provider support: --write and --validate-only are mutually exclusive", + ); +} + +const manifest = readJson(manifestFile); +const { GRAPH_PROVIDERS } = await import( + pathToFileURL( + path.join(root, "packages/graph/lib/provider/GRAPH_PROVIDERS.js"), + ).href +); +const { LANGUAGE_EXPERIMENTS } = await import( + pathToFileURL(path.join(root, "tests/experiment/src/catalog.mjs")).href +); +const benchmarkFile = path.resolve(root, manifest.benchmark?.artifact ?? ""); +const benchmark = readJson(benchmarkFile); + +validateManifest( + manifest, + GRAPH_PROVIDERS, + LANGUAGE_EXPERIMENTS, + benchmark, +); + +if (!validateOnly) { + const readme = fs.readFileSync(readmeFile, "utf8"); + const newline = readme.includes("\r\n") ? "\r\n" : "\n"; + const generated = [startMarker, renderSupport(manifest), endMarker] + .join("\n") + .replaceAll("\n", newline); + const next = replaceGeneratedBlock(readme, generated); + if (write) { + fs.writeFileSync(readmeFile, next); + } else if (next !== readme) { + throw new Error( + "provider support: README block is stale; run `pnpm provider-support:write` after building @samchon/graph", + ); + } +} + +function validateManifest( + support, + providers, + experiments, + benchmarkResult, +) { + invariant( + support.schemaVersion === 1, + "manifest schemaVersion must be 1", + ); + invariant( + Array.isArray(support.providers), + "manifest providers must be an array", + ); + invariant( + Array.isArray(support.ordinaryOnly), + "manifest ordinaryOnly must be an array", + ); + invariant( + support.benchmark?.kind === + "cold end-to-end strict versus strict-disabled pairs", + "benchmark kind must name the cold paired measurement", + ); + assertUrl(support.benchmark?.workflowRun, "benchmark workflowRun"); + invariant( + benchmarkResult.index?.schemaVersion === 2 && + Array.isArray(benchmarkResult.index?.cells), + "benchmark artifact must contain index schemaVersion 2 cells", + ); + + const providerNames = unique( + support.providers.map((provider) => provider.provider), + "manifest provider", + ); + const runtimeNames = providers.map((provider) => provider.name); + const missingProviders = runtimeNames.filter( + (provider) => !providerNames.includes(provider), + ); + const absentProviders = providerNames.filter( + (provider) => !runtimeNames.includes(provider), + ); + invariant( + missingProviders.length === 0, + `undocumented registered provider ${missingProviders.join(", ")}`, + ); + invariant( + absentProviders.length === 0, + `documented absent provider ${absentProviders.join(", ")}`, + ); + invariant( + equal(providerNames, runtimeNames), + "manifest providers must appear exactly once in GRAPH_PROVIDERS order", + ); + + const registeredLanguages = new Set(); + const benchmarkRows = new Map(); + for (const [index, documented] of support.providers.entries()) { + const provider = providers[index]; + invariant( + provider !== undefined && provider.name === documented.provider, + `documented absent provider ${documented.provider}`, + ); + for (const field of [ + "install", + "resolution", + "requirements", + "mode", + "nativeAnalysis", + "exportMerge", + "reuseResident", + "limitations", + "decline", + "fallback", + ]) { + invariant( + typeof documented[field] === "string" && + documented[field].trim() !== "", + `${documented.provider} must define ${field}`, + ); + } + invariant( + documented.status === "registered", + `${documented.provider} status must be registered`, + ); + invariant( + Array.isArray(documented.platforms) && + documented.platforms.length > 0, + `${documented.provider} must name supported platforms`, + ); + unique(documented.platforms, `${documented.provider} platform`); + for (const platform of documented.platforms) { + invariant( + supportedPlatforms.has(platform), + `${documented.provider} names unknown platform ${platform}`, + ); + } + invariant( + Array.isArray(documented.installSources) && + documented.installSources.length > 0, + `${documented.provider} must name install sources`, + ); + unique( + documented.installSources.map((source) => source.label), + `${documented.provider} install-source label`, + ); + unique( + documented.installSources.map((source) => source.url), + `${documented.provider} install-source URL`, + ); + for (const source of documented.installSources) { + invariant( + typeof source.label === "string" && source.label.trim() !== "", + `${documented.provider} install source must have a label`, + ); + assertUrl( + source.url, + `${documented.provider} install source ${source.label}`, + ); + } + assertUrl(documented.upstream, `${documented.provider} upstream`); + invariant( + Array.isArray(documented.childIssues) && + documented.childIssues.length > 0, + `${documented.provider} must name child issues`, + ); + unique(documented.childIssues, `${documented.provider} child issue`); + for (const issue of documented.childIssues) { + assertUrl(issue, `${documented.provider} child issue`); + } + unique(documented.languages, `${documented.provider} language`); + unique(documented.facts, `${documented.provider} fact`); + unique(documented.commands, `${documented.provider} command`); + unique( + documented.projectCommandSources ?? [], + `${documented.provider} project command source`, + ); + unique( + documented.environmentOverrides, + `${documented.provider} environment override`, + ); + invariant( + equal(documented.languages, provider.languages), + `${documented.provider} languages differ from GRAPH_PROVIDERS`, + ); + invariant( + documented.authority === provider.authority, + `${documented.provider} authority differs from GRAPH_PROVIDERS`, + ); + invariant( + equal(documented.facts, provider.facts), + `${documented.provider} facts differ from GRAPH_PROVIDERS`, + ); + invariant( + equal(documented.commands, provider.resolution?.commands), + `${documented.provider} commands differ from its resolver descriptor`, + ); + invariant( + equal( + documented.projectCommandSources ?? [], + provider.resolution?.projectCommandSources ?? [], + ), + `${documented.provider} project command sources differ from its resolver descriptor`, + ); + invariant( + equal( + documented.environmentOverrides, + provider.resolution?.environmentOverrides, + ), + `${documented.provider} environment overrides differ from its resolver descriptor`, + ); + invariant( + equal( + [...documented.experimentLanguages].sort(), + [...documented.languages].sort(), + ), + `${documented.provider} experiment languages must cover its registry languages`, + ); + invariant( + typeof documented.experimentTool === "string" && + documented.experimentTool !== "", + `${documented.provider} must name its experiment tool`, + ); + invariant( + Array.isArray(documented.experimentCapabilities) && + documented.experimentCapabilities.length > 0, + `${documented.provider} must name experiment capabilities`, + ); + + for (const language of documented.languages) { + invariant( + !registeredLanguages.has(language), + `registered language ${language} is documented more than once`, + ); + registeredLanguages.add(language); + const rows = experiments.filter( + (experiment) => experiment.language === language, + ); + invariant( + rows.length === 1, + `experiment catalog must contain one ${language} row`, + ); + const experiment = rows[0]; + invariant( + experiment.strictProvider === documented.provider, + `${language} experiment provider differs from the support manifest`, + ); + invariant( + experiment.strictAuthority === documented.authority, + `${language} experiment authority differs from the support manifest`, + ); + invariant( + experiment.strictTool === documented.experimentTool, + `${language} experiment tool differs from the support manifest`, + ); + invariant( + equal( + experiment.requiredCapabilities ?? [], + documented.experimentCapabilities, + ), + `${language} experiment capabilities differ from the support manifest`, + ); + if (experiment.strictReleaseBoundary !== undefined) { + for (const field of ["version", "warning", "reason"]) { + invariant( + typeof experiment.strictReleaseBoundary[field] === "string" && + experiment.strictReleaseBoundary[field] !== "", + `${language} strict release boundary must state ${field}`, + ); + } + } + for (const fact of experiment.semanticEdges ?? []) { + invariant( + documented.facts.includes(fact), + `${language} experiment requires undocumented ${fact} facts`, + ); + } + } + + invariant( + Array.isArray(documented.benchmarks) && + documented.benchmarks.length > 0, + `${documented.provider} must name benchmark evidence`, + ); + if (documented.benchmarkProvider !== undefined) { + invariant( + typeof documented.benchmarkProvider === "string" && + documented.benchmarkProvider.trim() !== "" && + documented.benchmarkProvider !== documented.provider, + `${documented.provider} benchmark provider must name a different non-empty producer`, + ); + } + for (const row of documented.benchmarks) { + invariant( + typeof row.project === "string" && row.project !== "", + `${documented.provider} has a benchmark without a project`, + ); + invariant( + !benchmarkRows.has(row.project), + `benchmark project ${row.project} is documented more than once`, + ); + benchmarkRows.set(row.project, { + provider: documented.provider, + benchmarkProvider: documented.benchmarkProvider, + row, + }); + } + } + + const ordinaryLanguages = unique( + support.ordinaryOnly.map((row) => row.language), + "ordinary-only language", + ); + for (const row of support.ordinaryOnly) { + invariant( + typeof row.server === "string" && row.server !== "", + `${row.language} must name its ordinary server`, + ); + invariant( + typeof row.reason === "string" && row.reason !== "", + `${row.language} must explain its ordinary-only status`, + ); + assertUrl(row.issue, `${row.language} issue`); + invariant( + !registeredLanguages.has(row.language), + `${row.language} cannot be both registered and ordinary-only`, + ); + const rows = experiments.filter( + (experiment) => experiment.language === row.language, + ); + invariant( + rows.length === 1 && + rows[0].strictProvider === undefined && + rows[0].strictTool === undefined, + `${row.language} experiment must remain ordinary-only`, + ); + } + const experimentLanguages = unique( + experiments.map((experiment) => experiment.language), + "experiment language", + ); + invariant( + equal( + [...registeredLanguages, ...ordinaryLanguages].sort(), + [...experimentLanguages].sort(), + ), + "support manifest must classify every experiment language exactly once", + ); + + const cells = benchmarkResult.index.cells; + const cellProjects = [...new Set(cells.map((cell) => cell.project))]; + invariant( + equal([...benchmarkRows.keys()].sort(), [...cellProjects].sort()), + "manifest benchmark projects must match the exact artifact", + ); + for (const [project, documented] of benchmarkRows) { + const strict = cells.filter( + (cell) => cell.project === project && cell.strict === true, + ); + const fallback = cells.filter( + (cell) => cell.project === project && cell.strict === false, + ); + invariant( + strict.length === 1 && fallback.length === 1, + `${project} must have one strict and one strict-disabled cell`, + ); + invariant( + strict[0].measurementId === fallback[0].measurementId, + `${project} benchmark cells must come from one paired measurement`, + ); + invariant( + strict[0].servedBy.includes( + documented.benchmarkProvider ?? documented.provider, + ), + `${project} strict cell does not name ${documented.benchmarkProvider ?? documented.provider}`, + ); + if ( + Object.hasOwn(documented.row, "strictTimedOutMs") || + Object.hasOwn(documented.row, "fallbackTimedOutMs") + ) { + invariant( + documented.row.strictTimedOutMs === strict[0].timedOutMs && + strict[0].buildMs === null && + documented.row.fallbackTimedOutMs === fallback[0].timedOutMs && + fallback[0].buildMs === null, + `${project} timeout limits differ from the benchmark artifact`, + ); + } else { + invariant( + documented.row.strictMs === strict[0].buildMs && + documented.row.fallbackMs === fallback[0].buildMs, + `${project} timings differ from the benchmark artifact`, + ); + } + } +} + +function renderSupport(manifest) { + const capabilityRows = manifest.providers.map((provider) => [ + code(provider.provider), + provider.languages.map(code).join(", "), + code(provider.authority), + provider.facts.length === 0 + ? "**none**" + : provider.facts.map(code).join(", "), + [ + `[upstream](${provider.upstream})`, + ...provider.childIssues.map( + (issue) => + `[route #${issue.slice(issue.lastIndexOf("/") + 1)}](${issue})`, + ), + ].join(" / "), + ]); + const lifecycleRows = manifest.providers.map((provider) => [ + code(provider.provider), + code(provider.mode), + provider.requirements, + provider.nativeAnalysis, + provider.exportMerge, + provider.reuseResident, + ]); + const installRows = manifest.providers.map((provider) => [ + code(provider.provider), + provider.install, + provider.installSources + .map((source) => `[${source.label}](${source.url})`) + .join(", "), + provider.commands.map(code).join(", "), + provider.projectCommandSources?.map(code).join(", ") ?? "—", + provider.environmentOverrides.map(code).join(", "), + provider.resolution, + provider.requirements, + provider.platforms.map(code).join(", "), + ]); + const troubleshootingRows = manifest.providers.map((provider) => [ + provider.languages.map(code).join(", "), + code(provider.provider), + provider.limitations, + provider.decline, + provider.fallback, + ]); + const benchmarkRows = manifest.providers.flatMap((provider) => + provider.benchmarks.map((benchmark) => [ + code(benchmark.project), + provider.benchmarkProvider === undefined + ? code(provider.provider) + : `${code(provider.benchmarkProvider)} (prior fallback evidence; ${code(provider.provider)} not yet measured)`, + Object.hasOwn(benchmark, "strictTimedOutMs") + ? `did not finish before ${seconds(benchmark.strictTimedOutMs)} s` + : milliseconds(benchmark.strictMs), + Object.hasOwn(benchmark, "fallbackTimedOutMs") + ? `did not finish before ${seconds(benchmark.fallbackTimedOutMs)} s` + : milliseconds(benchmark.fallbackMs), + ]), + ); + const ordinaryRows = manifest.ordinaryOnly.map((row) => [ + code(row.language), + code(row.server), + row.reason, + `[tracked route](${row.issue})`, + ]); + + return [ + "### Strict provider support", + "", + "_Generated from [`docs/provider-support.json`](https://github.com/samchon/compiler-graph/blob/master/docs/provider-support.json); do not edit this block by hand._", + "", + "Strict selection is per registered provider and may decline for missing tools, incompatible options, or incomplete build metadata. Authority grades differ. A provider's `facts` list means it can defend those edge families; it is not a universal-completeness claim. Strict dumps carry provider/tool provenance plus universe, input-manifest, and content digests. The MCP result reports operation coverage and uncertainty, but does not promise #63's future complete producer-owned per-generation coverage contract. Generic language-server and static fallbacks remain valid lower-authority results and are identified as such.", + "", + "#### Capability", + "", + table( + ["Provider", "Languages", "Authority", "Defensible facts", "Evidence"], + capabilityRows, + ), + "", + "#### Lifecycle", + "", + `These are current implementation modes, not future route claims. Preparation and native/export/resident phases are stated separately because the [experiment catalog](https://github.com/samchon/compiler-graph/blob/master/tests/experiment/src/catalog.mjs) and [cold measurement artifact](https://github.com/samchon/compiler-graph/blob/master/${manifest.benchmark.artifact}) prove different boundaries; the artifact reports whole end-to-end cells, not isolated phase timings.`, + "", + table( + ["Provider", "Mode", "Preparation", "Native analysis", "Export and merge", "Reuse or resident state"], + lifecycleRows, + ), + "", + "#### Installation and selection", + "", + "The troubleshooting table names the ordinary language-server/static fallback for each row. Resolution metadata is shared with the shipped registry and checked in CI.", + "", + table( + ["Provider", "Install", "Install sources", "Fixed commands", "Project command sources", "Overrides", "Resolution order", "Project preparation", "Platforms"], + installRows, + ), + "", + "#### Verified cold index cells", + "", + `These are exact same-run cold end-to-end strict/strict-disabled pairs from [\`${manifest.benchmark.artifact}\`](https://github.com/samchon/compiler-graph/blob/master/${manifest.benchmark.artifact}), produced by [the pinned workflow run](${manifest.benchmark.workflowRun}). They do not prove warm or semantic-incremental behavior. A zero-fact strict provider is not called semantically complete. Ruby and Dart report only that both whole cells exceeded the 1,800-second guard; that limit is not an isolated producer duration.`, + "", + table( + ["Project", "Strict provider", "Strict cell", "Strict-disabled cell"], + benchmarkRows, + ), + "", + "#### Troubleshooting", + "", + "A strict result's provenance name must equal the provider below. If it is absent, use the commands and overrides in the installation table, then follow the explicit decline reason; the fallback is still usable but does not inherit strict authority.", + "", + table( + ["Languages", "Expected provenance", "Common boundary", "Common decline", "Fallback"], + troubleshootingRows, + ), + "", + "#### Ordinary-only strict status", + "", + "These languages are indexed through their ordinary server and static fallback today. They have no registered strict provider or strict timing claim.", + "", + table( + ["Language", "Ordinary server", "Why no strict provider", "Route"], + ordinaryRows, + ), + ].join("\n"); +} + +function replaceGeneratedBlock(readme, generated) { + const start = readme.indexOf(startMarker); + const end = readme.indexOf(endMarker); + invariant(start !== -1 && end !== -1, "README support markers are missing"); + invariant( + readme.indexOf(startMarker, start + startMarker.length) === -1 && + readme.indexOf(endMarker, end + endMarker.length) === -1, + "README support markers must be unique", + ); + invariant(start < end, "README support markers are reversed"); + return `${readme.slice(0, start)}${generated}${readme.slice( + end + endMarker.length, + )}`; +} + +function table(headers, rows) { + return [ + `| ${headers.map(cell).join(" | ")} |`, + `| ${headers.map(() => "---").join(" | ")} |`, + ...rows.map((row) => `| ${row.map(cell).join(" | ")} |`), + ].join("\n"); +} + +function cell(value) { + return String(value).replaceAll("|", "\\|").replace(/\r?\n/g, " "); +} + +function code(value) { + return `\`${value}\``; +} + +function milliseconds(value) { + return `${new Intl.NumberFormat("en-US", { + maximumFractionDigits: 3, + minimumFractionDigits: 3, + useGrouping: true, + }).format(value)} ms`; +} + +function seconds(value) { + return new Intl.NumberFormat("en-US").format(value / 1_000); +} + +function readJson(file) { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch (error) { + throw new Error( + `provider support: cannot read ${path.relative(root, file)}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + +function assertUrl(value, label) { + try { + const url = new URL(value); + invariant( + url.protocol === "https:", + `${label} must be an HTTPS URL`, + ); + } catch (error) { + if (error instanceof Error && error.message.startsWith("provider support:")) { + throw error; + } + throw new Error(`provider support: ${label} is not a valid URL`); + } +} + +function unique(values, label) { + const rows = new Set(values); + invariant(rows.size === values.length, `${label} rows must be unique`); + return [...rows]; +} + +function equal(left, right) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +function invariant(condition, message) { + if (!condition) throw new Error(`provider support: ${message}`); +} diff --git a/packages/graph/package.json b/packages/graph/package.json index 143dc110..174c4980 100644 --- a/packages/graph/package.json +++ b/packages/graph/package.json @@ -15,8 +15,8 @@ "./package.json": "./package.json" }, "scripts": { - "build": "pnpm --filter @samchon/graph-sitter build && rimraf lib && ttsc -p tsconfig.json && node build/copy-sidecars.mjs && node build/bundle-viewer.mjs && node -e \"require('node:fs').copyFileSync('../../README.md', 'README.md')\"", - "prepublishOnly": "node build/copy-sidecars.mjs && node -e \"require('node:fs').copyFileSync('../../README.md', 'README.md')\"" + "build": "pnpm --filter @samchon/graph-sitter build && rimraf lib && ttsc -p tsconfig.json && node build/provider-support.mjs --check && node build/copy-sidecars.mjs && node build/bundle-viewer.mjs && node -e \"require('node:fs').copyFileSync('../../README.md', 'README.md')\"", + "prepublishOnly": "node build/provider-support.mjs --check && node build/copy-sidecars.mjs && node -e \"require('node:fs').copyFileSync('../../README.md', 'README.md')\"" }, "keywords": [ "mcp", diff --git a/packages/graph/src/SamchonGraphApplication.ts b/packages/graph/src/SamchonGraphApplication.ts index 48c4e62d..3de9e884 100644 --- a/packages/graph/src/SamchonGraphApplication.ts +++ b/packages/graph/src/SamchonGraphApplication.ts @@ -4,6 +4,7 @@ import { RESULT_AUDIT_DETAILS } from "./operations/RESULT_AUDIT_DETAILS"; import { RESULT_AUDIT_SELECTION } from "./operations/RESULT_AUDIT_SELECTION"; import { RESULT_AUDIT_ESCAPE } from "./operations/RESULT_AUDIT_ESCAPE"; import { resultNext } from "./operations/resultNext"; +import { graphTrust } from "./operations/graphTrust"; import { runDetails } from "./operations/runDetails"; import { runEntrypoints } from "./operations/runEntrypoints"; import { runLookup } from "./operations/runLookup"; @@ -11,6 +12,7 @@ import { runOverview } from "./operations/runOverview"; import { runTour } from "./operations/runTour"; import { runTrace } from "./operations/runTrace"; import { SamchonGraphMemory } from "./SamchonGraphMemory"; +import { SamchonRepositoryContextMemory } from "./repository"; import { ISamchonGraphApplication, ISamchonGraphEscape } from "./structures"; /** @@ -33,9 +35,20 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { private readonly graph: () => | SamchonGraphMemory | Promise; + private readonly topology: + | (() => + | SamchonRepositoryContextMemory + | Promise) + | undefined; - public constructor(source: AsyncSamchonGraphSource) { + public constructor( + source: AsyncSamchonGraphSource, + topology?: () => + | SamchonRepositoryContextMemory + | Promise, + ) { this.graph = typeof source === "function" ? source : () => source; + this.topology = topology; } public async inspect_code_graph( @@ -62,6 +75,7 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { const r = runEntrypoints(graph, props.request); return { audit: RESULT_AUDIT_SELECTION(graph.indexer), + ...graphTrust(graph, props.request.type), next: r.next, result: r.result, }; @@ -72,6 +86,7 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { const r = runLookup(graph, props.request); return { audit: RESULT_AUDIT_SELECTION(graph.indexer), + ...graphTrust(graph, props.request.type), next: r.next, result: r.result, }; @@ -80,6 +95,7 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { const r = runTrace(graph, props.request); return { audit: RESULT_AUDIT(graph.indexer), + ...graphTrust(graph, props.request.type), next: r.next, result: r.result, }; @@ -88,6 +104,7 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { const r = runDetails(graph, props.request); return { audit: RESULT_AUDIT_DETAILS(graph.indexer, props.request.memberLimit), + ...graphTrust(graph, props.request.type), next: r.next, result: r.result, }; @@ -96,6 +113,7 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { const r = runOverview(graph, props.request); return { audit: RESULT_AUDIT(graph.indexer), + ...graphTrust(graph, props.request.type), next: r.next, result: r.result, }; @@ -107,10 +125,101 @@ export class SamchonGraphApplication implements ISamchonGraphApplication { const r = runTour(graph, props.request, props.question); return { audit: RESULT_AUDIT_SELECTION(graph.indexer), + ...graphTrust(graph, props.request.type), next: r.next, result: r.result, }; } + case "topology": { + if (this.topology === undefined) { + throw new Error( + "@samchon/graph: repository-context source is unavailable", + ); + } + const topology = await this.topology(); + const confirmed = await this.load(); + const compatible = + graph.project === topology.dump.project && + topology.dump.provenance.length !== 0 && + graph.inputGeneration !== undefined && + graph.inputGeneration === confirmed.inputGeneration; + const join = compatible + ? { + state: "compatible" as const, + topologyInputGeneration: topology.dump.inputGeneration, + codeInputGeneration: graph.inputGeneration!, + } + : { + state: "unavailable" as const, + topologyInputGeneration: topology.dump.inputGeneration, + ...(graph.inputGeneration !== undefined + ? { codeInputGeneration: graph.inputGeneration } + : {}), + // One reason per condition, because a reason is a claim like any + // other, and this is the field a reader consults precisely when + // the joins they expected are missing. Four conditions withhold + // joins and the code can tell all four apart, so merging any two + // of them reports a cause that did not happen. + // + // The generation-absent case is the one a static server takes on + // every call: `startServer` strips the token from a `--graph-file` + // dump on purpose, because nothing revalidates it against the + // current checkout. Folding that into "the generation moved" + // would tell every such caller about a race that cannot occur + // there, for a token that was withheld deliberately. + reason: + graph.project !== topology.dump.project + ? "The code graph and the repository-context model describe different projects, so their file identities are not comparable." + : topology.dump.provenance.length === 0 + ? "No repository-context provider produced a compatible current generation." + : graph.inputGeneration === undefined + ? "This code graph carries no input generation to fence against: a graph file served without revalidation withholds one, and dumps written before cross-plane fencing never had one." + : "The code generation moved while topology was loading.", + }; + const result = topology.inspect( + props.request, + join, + new Set( + graph.nodes + .filter((node) => node.kind === "file") + .map((node) => node.file), + ), + ); + return { + audit: + "Repository topology is returned from declared or owning-tool models; file joins are included only when the code generation stayed stable across the topology load.", + // `answer` states that the result carries what the caller asked for + // and they should stop, so an empty one may not claim it. Which of + // the other two verdicts applies depends on why it is empty, and + // topology can tell: its query is exact equality against an id, a + // name or a coordinate, with no scoring and no near miss. + // + // So a query that matched nothing against a model that does hold + // nodes is `clarify` — the same call without it lists what exists, + // which is a restatement rather than an escape. Only a model with no + // nodes at all is `outside`, and then the repository plane really + // has nothing to say and the answer is elsewhere. Calling the first + // case `outside` would send a caller to read source over a spelling. + next: + result.nodes.length !== 0 + ? resultNext( + "answer", + result.truncated + ? "The requested repository orientation is present, and the result states that its configured bounds truncated additional facts." + : "The requested repository orientation is present in this topology result.", + ) + : topology.dump.nodes.length !== 0 + ? resultNext( + "clarify", + "No repository topology node has that exact id, name or coordinate; this plane matches exactly, so restate the request or drop the query to list what it holds.", + ) + : resultNext( + "outside", + "No repository-context provider published any topology node for this project, so the repository plane has nothing to answer from.", + ), + result, + }; + } default: props.request satisfies never; throw new Error("Unknown graph request type"); diff --git a/packages/graph/src/SamchonGraphMemory.ts b/packages/graph/src/SamchonGraphMemory.ts index f1098c5d..8f5d1aff 100644 --- a/packages/graph/src/SamchonGraphMemory.ts +++ b/packages/graph/src/SamchonGraphMemory.ts @@ -9,8 +9,10 @@ import { ISamchonGraphDump, ISamchonGraphEdge, ISamchonGraphEvidence, + ISamchonGraphCoverage, ISamchonGraphNode, ISamchonGraphSpan, + ISamchonGraphUnresolved, } from "./structures"; import { GraphLanguage } from "./typings"; import { basename } from "./utils/path"; @@ -40,6 +42,8 @@ export class SamchonGraphMemory { public readonly languages: readonly string[]; /** Which indexing strategy produced the graph. */ public readonly indexer: ISamchonGraphDump["indexer"]; + /** Complete coordinator input generation for cross-plane compatibility. */ + public readonly inputGeneration: string | undefined; /** Every node, raw plus synthesized (file containers). */ public readonly nodes: readonly ISamchonGraphNode[]; /** Every edge, raw plus synthesized containment. */ @@ -48,6 +52,12 @@ export class SamchonGraphMemory { public readonly diagnostics: readonly ISamchonGraphDiagnostic[]; /** Non-fatal problems encountered while building the graph. */ public readonly warnings: readonly string[]; + /** Strict-provider provenance retained from the exact dump generation. */ + public readonly provenance: readonly ISamchonGraphDump.IProvenance[]; + /** Machine-readable completeness of every strict relationship family. */ + public readonly coverage: readonly ISamchonGraphCoverage[]; + /** Exact sites whose relationships remain unresolved. */ + public readonly unresolved: readonly ISamchonGraphUnresolved[]; /** Provenance-gated source display facts owned by this exact snapshot. */ public readonly source: SamchonGraphSourceReader; @@ -60,10 +70,14 @@ export class SamchonGraphMemory { this.project = dump.project; this.languages = dump.languages; this.indexer = dump.indexer; + this.inputGeneration = dump.generation?.input; this.nodes = nodes; this.edges = edges; this.diagnostics = dump.diagnostics ?? []; this.warnings = dump.warnings ?? []; + this.provenance = dump.provenance ?? []; + this.coverage = dump.coverage ?? []; + this.unresolved = dump.unresolved ?? []; this.source = source; this.byId = indexNodesById(nodes); diff --git a/packages/graph/src/index.ts b/packages/graph/src/index.ts index eb3e3c12..23ce045a 100644 --- a/packages/graph/src/index.ts +++ b/packages/graph/src/index.ts @@ -7,6 +7,7 @@ export * from "./operations/RESULT_AUDIT_DETAILS"; export * from "./operations/RESULT_AUDIT_SELECTION"; export * from "./operations/RESULT_AUDIT_ESCAPE"; export * from "./provider"; +export * from "./repository"; export * from "./SamchonGraphMemory"; export * from "./SamchonGraphSourceReader"; export * from "./runGraph"; diff --git a/packages/graph/src/indexer/LANGUAGE_SPECS.ts b/packages/graph/src/indexer/LANGUAGE_SPECS.ts index 61fc77c7..a1db7fc6 100644 --- a/packages/graph/src/indexer/LANGUAGE_SPECS.ts +++ b/packages/graph/src/indexer/LANGUAGE_SPECS.ts @@ -32,6 +32,7 @@ export const LANGUAGE_SPECS: ILanguageSpec[] = [ ".hxx", ".h++", ".H", + ".h", ".ipp", ".tpp", ".tcc", diff --git a/packages/graph/src/indexer/buildGraphResult.ts b/packages/graph/src/indexer/buildGraphResult.ts index d8729b66..bf2425a9 100644 --- a/packages/graph/src/indexer/buildGraphResult.ts +++ b/packages/graph/src/indexer/buildGraphResult.ts @@ -22,5 +22,13 @@ export async function buildGraphResult( buildStaticGraphResult(normalized), ) : await buildLspGraph(normalized); - return { ...result, dump: parseGraphDump(result.dump) }; + return { + ...result, + dump: parseGraphDump({ + ...result.dump, + generation: { + input: result.inputGeneration!, + }, + }), + }; } diff --git a/packages/graph/src/indexer/buildLspGraph.ts b/packages/graph/src/indexer/buildLspGraph.ts index 7b3a7dc0..9f5dd9b2 100644 --- a/packages/graph/src/indexer/buildLspGraph.ts +++ b/packages/graph/src/indexer/buildLspGraph.ts @@ -8,7 +8,9 @@ import { ISamchonGraphDiagnostic, ISamchonGraphDump, ISamchonGraphEdge, + ISamchonGraphCoverage, ISamchonGraphNode, + ISamchonGraphUnresolved, } from "../structures"; import { GraphLanguage } from "../typings"; import { projectRelative, readText } from "../utils/fs"; @@ -16,6 +18,9 @@ import { fileFromUri, fileUri, isSubPath } from "../utils/path"; import { spawnableCommand } from "../utils/spawnableCommand"; import { assertGraphSnapshotContract } from "../provider/assertGraphSnapshotContract"; import { dumpProvenanceOf } from "../provider/dumpProvenanceOf"; +import { fallbackCoverage } from "../provider/fallbackCoverage"; +import { graphCoverageOf } from "../provider/graphCoverageOf"; +import { graphUnresolvedOf } from "../provider/graphUnresolvedOf"; import { IBulkGraphSession } from "../provider/IBulkGraphSession"; import { isBulkGraphSession } from "../provider/isBulkGraphSession"; import { mergeGraphSlices } from "../provider/mergeGraphSlices"; @@ -84,6 +89,10 @@ export async function buildLspGraph( ? [] : closeKeptSessions(result.sessions), ); + committed.dump = { + ...committed.dump, + generation: { input: committed.inputGeneration! }, + }; if (options.keepAlive) { const { providerSourceDigests: _providerSourceDigests, ...result } = committed; @@ -112,6 +121,8 @@ async function buildLspGraphAttempt( const strictNodes: ISamchonGraphNode[] = []; const strictEdges: ISamchonGraphEdge[] = []; const diagnostics: ISamchonGraphDiagnostic[] = []; + const coverage: ISamchonGraphCoverage[] = []; + const unresolved: ISamchonGraphUnresolved[] = []; const warnings: string[] = []; const staticFallbackLanguages: GraphLanguage[] = []; const sessions = new Map(); @@ -161,93 +172,100 @@ async function buildLspGraphAttempt( // empty log three times over: no provider named, no reason recorded, and no // way to tell a slow strict indexer from a slow fallback. announceProviderSelection(selection.candidates, selection.warnings); - for (const candidate of selection.candidates) { - try { - const { refresh, session } = - await resolvedDependencies.collectProviderGraph( - root, - candidate, - options, - ); - const snapshot = refresh.snapshot; + for (const selectedCandidate of selection.candidates) { + const attempts = [selectedCandidate, ...selectedCandidate.fallbacks]; + for (const [routeIndex, candidate] of attempts.entries()) { try { - assertGraphSnapshotContract( - snapshot, - candidate.provider, - candidate.languages, - root, - ); - // Closing a one-shot session is part of accepting its candidate. A - // close failure declines it before its manifest or facts can enter - // the aggregate. Resident candidates stay live only after the same - // collision gate admits their source evidence. - if (!options.keepAlive) await session.close(); - mergeProviderSourceDigests(strictDigests, snapshot.sources); - } catch (error) { - // `collectProviderGraph` has handed this live session to the - // coordinator, but a rejected snapshot never enters `sessions`. - // Close it here: otherwise a resident build falls through to the - // generic lane while the invalid provider's child remains orphaned. + const { refresh, session } = + await resolvedDependencies.collectProviderGraph( + root, + candidate, + options, + ); + const snapshot = refresh.snapshot; try { - await session.close(); - } catch (closeError) { - throw new AggregateError( - [error, closeError], - "@samchon/graph: strict provider snapshot was refused and its unpublished session could not close", + assertGraphSnapshotContract( + snapshot, + candidate.provider, + candidate.languages, + root, ); + // Closing a one-shot session is part of accepting its candidate. A + // close failure declines it before its manifest or facts can enter + // the aggregate. Resident candidates stay live only after the same + // collision gate admits their source evidence. + if (!options.keepAlive) await session.close(); + mergeProviderSourceDigests(strictDigests, snapshot.sources); + } catch (error) { + // `collectProviderGraph` has handed this live session to the + // coordinator, but a rejected snapshot never enters `sessions`. + // Close it here: otherwise a resident build falls through to the + // generic lane while the invalid provider's child remains orphaned. + try { + await session.close(); + } catch (closeError) { + throw new AggregateError( + [error, closeError], + "@samchon/graph: strict provider snapshot was refused and its unpublished session could not close", + ); + } + throw error; } - throw error; - } - appendAll(strictNodes, snapshot.nodes); - appendAll(strictEdges, snapshot.edges); - appendAll(diagnostics, snapshot.diagnostics); - appendAll(warnings, snapshot.warnings); - // The manifest names the files, and the provider owns the fact that it - // does. Nothing reads their text here: the strict lane's facts are - // already resolved, and the only thing the generic lane wanted text for - // — deriving export edges — is work this provider has already done - // against the real checker. - provenance.push(dumpProvenanceOf(snapshot)); - modes.set(candidate.provider.name, refresh.mode); - // A complete strict slice can legitimately contain no declarations. - // The provider still answered for its languages, with provenance, - // diagnostics, and an exact manifest. Counting nodes as proof that it - // answered relabelled that valid empty slice as static fallback and - // let a later resident generation change lane authority underneath the - // same kept session. - semanticSliceCount += 1; - // A candidate may own more languages than its snapshot published — a - // Clang provider asked for C and C++ can answer with only the - // translation units it found. Whatever it did not publish falls to the - // generic lane, and that has to be said: a caller who selected a - // compiler-owned provider for C would otherwise be handed navigation - // facts for it with nothing to distinguish them. - const published = new Set(snapshot.languages); - const unpublished = candidate.languages.filter( - (language) => !published.has(language), - ); - if (unpublished.length > 0) { - warnings.push( - `${unpublished.join(", ")}: the ${candidate.provider.name} ${candidate.provider.authority} provider owns these languages but published no slice for them, so they fall through to the generic language-server lane.`, + appendAll(strictNodes, snapshot.nodes); + appendAll(strictEdges, snapshot.edges); + appendAll(diagnostics, snapshot.diagnostics); + appendAll(coverage, graphCoverageOf(snapshot)); + appendAll(unresolved, graphUnresolvedOf(snapshot)); + appendAll(warnings, snapshot.warnings); + // The manifest names the files, and the provider owns the fact that it + // does. Nothing reads their text here: the strict lane's facts are + // already resolved, and the only thing the generic lane wanted text for + // — deriving export edges — is work this provider has already done + // against the real checker. + provenance.push(dumpProvenanceOf(snapshot)); + modes.set(candidate.provider.name, refresh.mode); + // A complete strict slice can legitimately contain no declarations. + // The provider still answered for its languages, with provenance, + // diagnostics, and an exact manifest. Counting nodes as proof that it + // answered relabelled that valid empty slice as static fallback and + // let a later resident generation change lane authority underneath the + // same kept session. + semanticSliceCount += 1; + // A candidate may own more languages than its snapshot published — a + // Clang provider asked for C and C++ can answer with only the + // translation units it found. Whatever it did not publish falls to the + // generic lane, and that has to be said: a caller who selected a + // compiler-owned provider for C would otherwise be handed navigation + // facts for it with nothing to distinguish them. + const published = new Set(snapshot.languages); + const unpublished = candidate.languages.filter( + (language) => !published.has(language), ); - } - for (const language of snapshot.languages) { - strictLanguages.add(language); - servedLanguages.add(language); - // A multi-language provider is one session under several keys. The - // map stays keyed by language because every consumer asks it a - // language question; deduplication is the consumers' job and they do - // it by session identity, not by key. - if (options.keepAlive) { - sessions.set(language, session); - providers.set(language, candidate.provider); + if (unpublished.length > 0) { + warnings.push( + `${unpublished.join(", ")}: the ${candidate.provider.name} ${candidate.provider.authority} provider owns these languages but published no slice for them, so they fall through to the generic language-server lane.`, + ); } + for (const language of snapshot.languages) { + strictLanguages.add(language); + servedLanguages.add(language); + // A multi-language provider is one session under several keys. The + // map stays keyed by language because every consumer asks it a + // language question; deduplication is the consumers' job and they do + // it by session identity, not by key. + if (options.keepAlive) { + sessions.set(language, session); + providers.set(language, candidate.provider); + } + } + break; + } catch (error) { + if (options.signal?.aborted) throw error; + const next = attempts[routeIndex + 1]; + warnings.push( + `${candidate.languages.join(", ")}: the ${candidate.provider.name} ${candidate.provider.authority} provider failed, so these languages fall through to ${next === undefined ? "the generic language-server lane" : `the ${next.provider.name} ${next.provider.authority} provider`}: ${(error as Error).message}`, + ); } - } catch (error) { - if (options.signal?.aborted) throw error; - warnings.push( - `${candidate.languages.join(", ")}: the ${candidate.provider.name} ${candidate.provider.authority} provider failed, so these languages fall through to the generic language-server lane: ${(error as Error).message}`, - ); } } @@ -334,6 +352,7 @@ async function buildLspGraphAttempt( appendAll(nodes, result.nodes); appendAll(edges, result.edges); appendAll(diagnostics, result.diagnostics); + appendAll(coverage, fallbackCoverage("@samchon/graph-lsp", [language])); appendAll(warnings, result.warnings); semanticSliceCount += 1; servedLanguages.add(language); @@ -375,6 +394,10 @@ async function buildLspGraphAttempt( } appendAll(nodes, fallback.nodes); appendAll(edges, fallback.edges); + appendAll( + coverage, + fallbackCoverage("@samchon/graph-sitter", fallback.languages), + ); appendAll(warnings, fallback.warnings); } @@ -413,6 +436,8 @@ async function buildLspGraphAttempt( nodes: wireNodes(finalized.nodes), edges: wireEdges(finalized.edges, finalized.nodes), diagnostics, + coverage, + unresolved, warnings, ...dumpProvenanceOf.fieldOf(provenance), }, @@ -477,7 +502,7 @@ async function closeKeptSessions( */ async function collectProviderGraph( root: string, - candidate: selectGraphProviders.ICandidate, + candidate: selectGraphProviders.IRouteCandidate, options: IBuildGraphOptions, ): Promise<{ refresh: IBulkGraphSession.IRefresh; @@ -519,7 +544,7 @@ async function collectProviderGraph( /** A provider may not widen or move the candidate the registry selected. */ function assertBulkSessionContract( root: string, - candidate: selectGraphProviders.ICandidate, + candidate: selectGraphProviders.IRouteCandidate, session: IBulkGraphSession, ): void { const label = `@samchon/graph: provider "${candidate.provider.name}"`; @@ -577,6 +602,8 @@ function staticDump( project: parts.root, languages: parts.languages, indexer: "static", + coverage: fallbackCoverage("@samchon/graph-sitter", parts.languages), + unresolved: [], nodes: wireNodes(nodes), edges: wireEdges(dedupeEdges(finalized.edges), nodes), warnings: [...parts.warnings, ...warnings, ...dedupeWarnings], diff --git a/packages/graph/src/indexer/buildStaticGraphResult.ts b/packages/graph/src/indexer/buildStaticGraphResult.ts index b63b35e5..bbc87005 100644 --- a/packages/graph/src/indexer/buildStaticGraphResult.ts +++ b/packages/graph/src/indexer/buildStaticGraphResult.ts @@ -1,5 +1,6 @@ import { SamchonGraphSourceReader } from "../SamchonGraphSourceReader"; import { ISamchonGraphDump } from "../structures"; +import { fallbackCoverage } from "../provider/fallbackCoverage"; import { dedupeEdges } from "./dedupeEdges"; import { dedupeNodes } from "./dedupeNodes"; import { finalizeGraph } from "./finalizeGraph"; @@ -30,6 +31,8 @@ export function buildStaticGraphResult( project: parts.root, languages: parts.languages, indexer: "static", + coverage: fallbackCoverage("@samchon/graph-sitter", parts.languages), + unresolved: [], nodes: wireNodes(nodes), edges: wireEdges(dedupeEdges(finalized.edges), nodes), warnings, diff --git a/packages/graph/src/indexer/createResidentGraphSource.ts b/packages/graph/src/indexer/createResidentGraphSource.ts index 1f92de08..5c8553f9 100644 --- a/packages/graph/src/indexer/createResidentGraphSource.ts +++ b/packages/graph/src/indexer/createResidentGraphSource.ts @@ -4,12 +4,17 @@ import { ISamchonGraphDiagnostic, ISamchonGraphDump, ISamchonGraphEdge, + ISamchonGraphCoverage, ISamchonGraphNode, + ISamchonGraphUnresolved, } from "../structures"; import { GraphLanguage } from "../typings"; import { SamchonGraphSourceReader } from "../SamchonGraphSourceReader"; import { assertGraphSnapshotContract } from "../provider/assertGraphSnapshotContract"; import { dumpProvenanceOf } from "../provider/dumpProvenanceOf"; +import { fallbackCoverage } from "../provider/fallbackCoverage"; +import { graphCoverageOf } from "../provider/graphCoverageOf"; +import { graphUnresolvedOf } from "../provider/graphUnresolvedOf"; import { IGraphProvider } from "../provider/IGraphProvider"; import { GRAPH_PROVIDERS } from "../provider/GRAPH_PROVIDERS"; import { IBulkGraphSession } from "../provider/IBulkGraphSession"; @@ -26,6 +31,7 @@ import { IBuildGraphOptions } from "./IBuildGraphOptions"; import { ILspSession } from "./ILspSession"; import { IResidentGraphSource } from "./IResidentGraphSource"; import { languageOf } from "./languageOf"; +import { languagesOf as sourceLanguagesOf } from "./languagesOf"; import { mergeProviderSourceDigests } from "./mergeProviderSourceDigests"; import { movedConsumedSource } from "./movedConsumedSource"; import { movedProviderSource } from "./movedProviderSource"; @@ -239,6 +245,8 @@ export function createResidentGraphSource( // dump whose own contract is that it is a function of its source (§6a). The // session holds them per file now, and a `didClose` drops the file's. const diagnostics: ISamchonGraphDiagnostic[] = []; + const coverage: ISamchonGraphCoverage[] = []; + const unresolved: ISamchonGraphUnresolved[] = []; const warnings: string[] = []; const sources = new Map(); const generations = new Map(current.generations); @@ -285,6 +293,8 @@ export function createResidentGraphSource( // the edges, and for the same reason the LSP lane stopped carrying them // forward: a diagnostic belongs to the generation that produced it. diagnostics.push(...refresh.snapshot.diagnostics); + coverage.push(...graphCoverageOf(refresh.snapshot)); + unresolved.push(...graphUnresolvedOf(refresh.snapshot)); warnings.push(...refresh.snapshot.warnings); provenance.push(dumpProvenanceOf(refresh.snapshot)); modes.set(refresh.snapshot.provenance.provider, refresh.mode); @@ -303,6 +313,7 @@ export function createResidentGraphSource( nodes.push(...result.nodes); edges.push(...result.edges); diagnostics.push(...result.diagnostics); + coverage.push(...fallbackCoverage("@samchon/graph-lsp", [language])); warnings.push(...result.warnings); for (const opened of session.opened.values()) { sources.set(opened.abs, opened.text); @@ -321,6 +332,9 @@ export function createResidentGraphSource( nodes.push(...fallback.nodes); edges.push(...fallback.edges); warnings.push(...fallback.warnings); + coverage.push( + ...fallbackCoverage("@samchon/graph-sitter", fallback.languages), + ); for (const [file, text] of fallback.sources) sources.set(file, text); } @@ -431,9 +445,12 @@ export function createResidentGraphSource( project: current.dump.project, languages: current.dump.languages, indexer: current.dump.indexer, + generation: { input: inputGeneration }, nodes: wireNodes(finalized.nodes), edges: wireEdges(finalized.edges, finalized.nodes), diagnostics, + coverage, + unresolved, warnings, ...dumpProvenanceOf.fieldOf(provenance), }; @@ -958,10 +975,19 @@ function snapshotSources( options: IBuildGraphOptions, excludedLanguages: ReadonlySet = new Set(), ): Map { - const files = selectGraphSources(root, options).files; + const selected = selectGraphSources(root, options); + const activeLanguages = new Set(selected.languages); const snapshot = new Map(); - for (const abs of files) { - if (excludedLanguages.has(languageOf(abs))) continue; + for (const abs of selected.files) { + const owners = sourceLanguagesOf(abs).filter((language) => + activeLanguages.has(language), + ); + if ( + owners.length > 0 && + owners.every((language) => excludedLanguages.has(language)) + ) { + continue; + } const text = readText(abs); // A file removed between the walk and the read is simply absent from the // snapshot, which itself is a difference the next comparison will catch. diff --git a/packages/graph/src/indexer/index.ts b/packages/graph/src/indexer/index.ts index 52bb3a94..8aeb21c4 100644 --- a/packages/graph/src/indexer/index.ts +++ b/packages/graph/src/indexer/index.ts @@ -27,6 +27,7 @@ export * from "./IStaticGraphParts"; export * from "./LANGUAGE_SPECS"; export * from "./languageIdOf"; export * from "./languageOf"; +export * from "./languagesOf"; export * from "./markClosures"; export * from "./markIgnored"; export * from "./normalizeRequestedLanguages"; diff --git a/packages/graph/src/indexer/languageOf.ts b/packages/graph/src/indexer/languageOf.ts index afe0fbd0..681e45bf 100644 --- a/packages/graph/src/indexer/languageOf.ts +++ b/packages/graph/src/indexer/languageOf.ts @@ -1,17 +1,11 @@ -import path from "node:path"; import { GraphLanguage } from "../typings"; -import { LANGUAGE_SPECS } from "./LANGUAGE_SPECS"; +import { languagesOf } from "./languagesOf"; export function languageOf(file: string): GraphLanguage { - const exact = path.extname(file); - for (const spec of LANGUAGE_SPECS) { - if (spec.extensions.includes(exact)) return spec.language; - } - const folded = exact.toLowerCase(); - if (folded !== exact) { - for (const spec of LANGUAGE_SPECS) { - if (spec.extensions.includes(folded)) return spec.language; - } - } - return "unknown"; + const candidates = languagesOf(file); + // Compatibility surfaces with one language retain C as the default for a + // shared .h. Indexing uses languagesOf() and therefore never partitions the + // header away from C++ before semantic ownership can be resolved. + if (candidates.includes("c")) return "c"; + return candidates[0] ?? "unknown"; } diff --git a/packages/graph/src/indexer/languages.ts b/packages/graph/src/indexer/languages.ts index dd9bb771..aaba3a28 100644 --- a/packages/graph/src/indexer/languages.ts +++ b/packages/graph/src/indexer/languages.ts @@ -2,4 +2,5 @@ export * from "./allExtensions"; export * from "./ILanguageSpec"; export * from "./LANGUAGE_SPECS"; export * from "./languageOf"; +export * from "./languagesOf"; export * from "./specOf"; diff --git a/packages/graph/src/indexer/languagesOf.ts b/packages/graph/src/indexer/languagesOf.ts new file mode 100644 index 00000000..9634b7f8 --- /dev/null +++ b/packages/graph/src/indexer/languagesOf.ts @@ -0,0 +1,30 @@ +import path from "node:path"; + +import { GraphLanguage } from "../typings"; +import { LANGUAGE_SPECS } from "./LANGUAGE_SPECS"; + +/** Every exact or case-folded language owner registered for one source. */ +export function languagesOf( + file: string, +): Exclude[] { + const exact = path.extname(file); + const exactMatches: Exclude[] = []; + for (const spec of LANGUAGE_SPECS) { + if (spec.language !== "unknown" && spec.extensions.includes(exact)) { + exactMatches.push(spec.language); + } + } + if (exactMatches.length > 0) return exactMatches; + const folded = exact.toLowerCase(); + if (folded !== exact) { + for (const spec of LANGUAGE_SPECS) { + if ( + spec.language !== "unknown" && + spec.extensions.includes(folded) + ) { + exactMatches.push(spec.language); + } + } + } + return exactMatches; +} diff --git a/packages/graph/src/indexer/parseGraphDump.ts b/packages/graph/src/indexer/parseGraphDump.ts index fe15dcaa..6ba66a9f 100644 --- a/packages/graph/src/indexer/parseGraphDump.ts +++ b/packages/graph/src/indexer/parseGraphDump.ts @@ -3,6 +3,7 @@ import path from "node:path"; import typia from "typia"; import { ISamchonGraphDump, ISamchonGraphSpan } from "../structures"; +import { GRAPH_EDGE_KINDS } from "../typings"; import { validateSemanticGraphNode } from "../provider/semanticIdentity"; import { fileOfNodeId } from "../utils/fileOfNodeId"; @@ -25,6 +26,18 @@ export function parseGraphDump(input: unknown): ISamchonGraphDump { const files = new Set(); const dumpLanguages = new Set(dump.languages); for (const node of dump.nodes) { + if ( + node.id === "" || + node.id.includes("\0") || + node.name === "" || + node.name.includes("\0") || + node.qualifiedName === "" || + node.qualifiedName?.includes("\0") === true + ) { + throw new Error( + "@samchon/graph: node identity and display names must be non-empty and NUL-free", + ); + } if (node.file === "") { if (!node.external || node.kind !== "external_symbol") { throw new Error( @@ -104,9 +117,13 @@ export function parseGraphDump(input: unknown): ISamchonGraphDump { const providers = new Set(); for (const row of dump.provenance ?? []) { - if (row.provider === "" || providers.has(row.provider)) { + if ( + row.provider === "" || + row.provider.includes("\0") || + providers.has(row.provider) + ) { throw new Error( - `@samchon/graph: duplicate or empty provenance provider: ${row.provider}`, + `@samchon/graph: duplicate, empty, or NUL-delimited provenance provider: ${row.provider}`, ); } providers.add(row.provider); @@ -146,9 +163,115 @@ export function parseGraphDump(input: unknown): ISamchonGraphDump { } } } + const coverage = new Map< + string, + NonNullable[number] + >(); + const coverageSlices = new Map< + string, + Pick< + NonNullable[number], + "provider" | "language" | "target" + > + >(); + for (const row of dump.coverage ?? []) { + if ( + row.provider === "" || + row.provider.includes("\0") || + row.target === "" || + row.target.includes("\0") || + !dumpLanguages.has(row.language) + ) { + throw new Error("@samchon/graph: coverage row has invalid ownership"); + } + const key = coverageKey(row); + if (coverage.has(key)) { + throw new Error(`@samchon/graph: duplicate coverage row: ${key}`); + } + coverage.set(key, row); + coverageSlices.set( + coverageSliceKey(row), + { + provider: row.provider, + language: row.language, + target: row.target, + }, + ); + } + if (dump.coverage !== undefined) { + for (const slice of coverageSlices.values()) + for (const family of GRAPH_EDGE_KINDS) { + const key = coverageKey({ ...slice, family }); + if (!coverage.has(key)) { + throw new Error(`@samchon/graph: coverage is not exhaustive: ${key}`); + } + } + for (const provenance of dump.provenance ?? []) { + for (const language of provenance.languages) { + if ( + ![...coverageSlices.values()].some( + (slice) => + slice.provider === provenance.provider && + slice.language === language, + ) + ) { + throw new Error( + `@samchon/graph: coverage is missing for ${provenance.provider}/${language}`, + ); + } + } + } + } + const unresolved = new Set(); + for (const row of dump.unresolved ?? []) { + validateSpan(row.evidence, undefined, "unresolved evidence"); + assertUnique(row.candidates ?? [], "unresolved candidate"); + const owner = (dump.provenance ?? []).find( + (candidate) => + candidate.provider === row.provider && + candidate.languages.includes(row.language), + ); + if ( + !/^[0-9a-f]{64}$/.test(row.universe) || + owner === undefined || + owner.universe !== row.universe + ) { + throw new Error( + "@samchon/graph: unresolved site has no matching provider universe", + ); + } + const covered = coverage.get(coverageKey(row)); + if (covered?.state !== "partial") { + throw new Error( + "@samchon/graph: unresolved site does not have partial coverage", + ); + } + const key = JSON.stringify(row); + if (unresolved.has(key)) { + throw new Error("@samchon/graph: duplicate unresolved site"); + } + unresolved.add(key); + } return dump; } +function coverageKey(row: { + provider: string; + language: string; + target: string; + family: string; +}): string { + return `${row.provider}\0${row.language}\0${row.target}\0${row.family}`; +} + +function coverageSliceKey(row: { + provider: string; + language: string; + target: string; +}): string { + return `${row.provider}\0${row.language}\0${row.target}`; +} + function validateEndpoint( endpoint: string, side: "source" | "target", @@ -205,6 +328,7 @@ function validateGraphPath(file: string, label: string): void { const relative = file.slice("bundled:///".length); if ( relative === "" || + relative.includes("\0") || relative.includes("\\") || path.posix.normalize(relative) !== relative || relative.split("/").some((part) => part === "" || part === "." || part === "..") @@ -216,6 +340,7 @@ function validateGraphPath(file: string, label: string): void { const parts = file.split("/"); if ( file === "" || + file.includes("\0") || file.includes("\\") || /^[A-Za-z]:\//.test(file) || path.posix.isAbsolute(file) || diff --git a/packages/graph/src/indexer/selectGraphSources.ts b/packages/graph/src/indexer/selectGraphSources.ts index ada27b06..df91b755 100644 --- a/packages/graph/src/indexer/selectGraphSources.ts +++ b/packages/graph/src/indexer/selectGraphSources.ts @@ -3,7 +3,7 @@ import { walkSourceFiles } from "../utils/fs"; import { allExtensions } from "./allExtensions"; import { IBuildGraphOptions } from "./IBuildGraphOptions"; import { IGraphSourceSelection } from "./IGraphSourceSelection"; -import { languageOf } from "./languageOf"; +import { languagesOf } from "./languagesOf"; import { normalizeRequestedLanguages } from "./normalizeRequestedLanguages"; /** @@ -23,11 +23,14 @@ export function selectGraphSources( maxFiles: options.maxFiles, }); const byLanguage = new Map(); + const allowed = requested === undefined ? undefined : new Set(requested); for (const file of files) { - const language = languageOf(file); - const partition = byLanguage.get(language); - if (partition === undefined) byLanguage.set(language, [file]); - else partition.push(file); + for (const language of languagesOf(file)) { + if (allowed !== undefined && !allowed.has(language)) continue; + const partition = byLanguage.get(language); + if (partition === undefined) byLanguage.set(language, [file]); + else partition.push(file); + } } const presentLanguages = [...byLanguage.keys()]; return { diff --git a/packages/graph/src/indexer/staticGraphParts.ts b/packages/graph/src/indexer/staticGraphParts.ts index cb5d80bb..ca123737 100644 --- a/packages/graph/src/indexer/staticGraphParts.ts +++ b/packages/graph/src/indexer/staticGraphParts.ts @@ -12,7 +12,9 @@ import { GraphLanguage } from "../typings"; import { projectRelative, readText } from "../utils/fs"; import { IBuildGraphOptions } from "./IBuildGraphOptions"; import { IStaticGraphParts } from "./IStaticGraphParts"; -import { languageOf } from "./languages"; +import { languageOf } from "./languageOf"; +import { languagesOf } from "./languages"; +import { normalizeRequestedLanguages } from "./normalizeRequestedLanguages"; import { selectGraphSources } from "./selectGraphSources"; /** @@ -25,21 +27,22 @@ export function staticGraphParts( ): IStaticGraphParts { const root = path.resolve(options.cwd ?? process.cwd()); const discovered = selectedFiles ?? selectGraphSources(root, options).files; + const requested = normalizeRequestedLanguages(options.languages); + const allowed = requested === undefined ? undefined : new Set(requested); + const contextualLanguages = new Set(); + for (const absolutePath of discovered) { + const owners = staticOwners(absolutePath, allowed); + if (owners.length === 1) contextualLanguages.add(owners[0]!); + } const files: IGraphSitterFile[] = []; for (const absolutePath of discovered) { - const language = languageOf(absolutePath); - // `discovered` comes from `walkSourceFiles(allExtensions(...))`, so every - // path's extension maps — through the same `LANGUAGE_SPECS` registry that - // `allExtensions` and `languageOf` share — to a real (non-`unknown`) - // language, and `GraphSitterLanguage` covers every non-`unknown` - // `GraphLanguage` (the LanguageContractParity assertion below). This - // narrowing guard therefore never continues at runtime; it only satisfies - // the compiler that `language` is a `GraphSitterLanguage`. - /* c8 ignore next */ - if (!isGraphSitterLanguage(language)) continue; const source = readText(absolutePath); /* c8 ignore next */ if (source === undefined) continue; + const owners = staticOwners(absolutePath, allowed); + const language = staticOwner(absolutePath, owners, contextualLanguages); + /* c8 ignore next -- normal discovery cannot return a path outside its requested registry. */ + if (language === undefined) continue; files.push({ absolutePath, relativePath: projectRelative(root, absolutePath), @@ -51,6 +54,32 @@ export function staticGraphParts( return parts; } +/** Keep graph-sitter's file identity singular while honoring explicit filters. */ +function staticOwners( + absolutePath: string, + allowed: ReadonlySet | undefined, +): GraphSitterLanguage[] { + return languagesOf(absolutePath).filter( + (language): language is GraphSitterLanguage => + (allowed === undefined || allowed.has(language)) && + isGraphSitterLanguage(language), + ); +} + +/** Resolve a shared header from the unambiguous translation units around it. */ +function staticOwner( + absolutePath: string, + owners: readonly GraphSitterLanguage[], + contextualLanguages: ReadonlySet, +): GraphSitterLanguage | undefined { + if (owners.length <= 1) return owners[0]; + if (owners.includes("cpp") && contextualLanguages.has("cpp")) return "cpp"; + if (owners.includes("c") && contextualLanguages.has("c")) return "c"; + // Multiple supported owners currently means a shared .h with both owners + // still allowed, so the singular compatibility owner is one of this set. + return languageOf(absolutePath) as GraphSitterLanguage; +} + // The package boundary is intentionally structural and acyclic. These // bidirectional checks make any raw node, edge, or language drift a compile // failure before an adapter can silently weaken the public graph contract. diff --git a/packages/graph/src/lsp/LspClient.ts b/packages/graph/src/lsp/LspClient.ts index c6e71962..b788eee3 100644 --- a/packages/graph/src/lsp/LspClient.ts +++ b/packages/graph/src/lsp/LspClient.ts @@ -2,6 +2,7 @@ import { ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import { EventEmitter } from "node:events"; import { ownedProcess } from "../utils/ownedProcess"; +import { LspResponseError } from "./LspResponseError"; const SHUTDOWN_GRACE_MS = 1_000; const DEFAULT_MAX_MESSAGE_BYTES = 256 * 1024 * 1024; @@ -36,6 +37,7 @@ export class LspClient { maxMessageBytes = DEFAULT_MAX_MESSAGE_BYTES, windowsVerbatimArguments?: boolean, private readonly requestObserver?: LspClient.IRequestObserver, + private readonly serverRequestHandler?: LspClient.IServerRequestHandler, ) { if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes < 1) { throw new TypeError( @@ -291,7 +293,7 @@ export class LspClient { method?: string; params?: unknown; result?: unknown; - error?: { message?: string }; + error?: { code?: number; message?: string; data?: unknown }; }; } catch { continue; @@ -305,14 +307,29 @@ export class LspClient { method?: string; params?: unknown; result?: unknown; - error?: { message?: string }; + error?: { code?: number; message?: string; data?: unknown }; }): void { // A server-initiated request carries both an id and a method. It must be // answered or some servers block: gopls, for instance, withholds // documentSymbol until its `window/workDoneProgress/create` request is // acknowledged. A null result satisfies the acknowledgements we advertise. if (message.id !== undefined && message.method !== undefined) { - this.write({ jsonrpc: "2.0", id: message.id, result: null }); + if (this.serverRequestHandler === undefined) { + this.write({ jsonrpc: "2.0", id: message.id, result: null }); + return; + } + void Promise.resolve() + .then(() => this.serverRequestHandler!(message.method!, message.params)) + .then((result) => + this.write({ jsonrpc: "2.0", id: message.id, result: result ?? null }), + ) + .catch((error: unknown) => + this.write({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32603, message: asError(error).message }, + }), + ); return; } if (message.id !== undefined) { @@ -321,7 +338,11 @@ export class LspClient { this.deletePending(message.id, pending); if (message.error !== undefined) { pending.reject( - new Error(message.error.message ?? "LSP request failed."), + new LspResponseError( + message.error.code ?? -32603, + message.error.message ?? "LSP request failed.", + message.error.data, + ), ); } else { pending.resolve(message.result); @@ -388,6 +409,10 @@ export class LspClient { export namespace LspClient { export type IRequestObserver = (event: IRequestTrace) => void; + export type IServerRequestHandler = ( + method: string, + params: unknown, + ) => unknown; export type IRequestTrace = | { diff --git a/packages/graph/src/lsp/LspResponseError.ts b/packages/graph/src/lsp/LspResponseError.ts new file mode 100644 index 00000000..cda49bfd --- /dev/null +++ b/packages/graph/src/lsp/LspResponseError.ts @@ -0,0 +1,11 @@ +export class LspResponseError extends Error { + public readonly name = "LspResponseError"; + + public constructor( + public readonly code: number, + message: string, + public readonly data?: unknown, + ) { + super(message); + } +} diff --git a/packages/graph/src/lsp/index.ts b/packages/graph/src/lsp/index.ts index d5f3ea19..8c149d1b 100644 --- a/packages/graph/src/lsp/index.ts +++ b/packages/graph/src/lsp/index.ts @@ -7,3 +7,4 @@ export * from "./IRange"; export * from "./ISymbolInformation"; export * from "./isDocumentSymbol"; export * from "./LspClient"; +export * from "./LspResponseError"; diff --git a/packages/graph/src/mcp/createCompositeResidentClose.ts b/packages/graph/src/mcp/createCompositeResidentClose.ts new file mode 100644 index 00000000..2763f039 --- /dev/null +++ b/packages/graph/src/mcp/createCompositeResidentClose.ts @@ -0,0 +1,24 @@ +/** Close every opened resident plane while retaining the first failure. */ +export function createCompositeResidentClose( + residents: readonly ( + | { close(): Promise } + | undefined + )[], +): { close(): Promise } { + return { + async close(): Promise { + let failure: unknown; + for (const resident of residents) { + if (resident === undefined) continue; + try { + await resident.close(); + } catch (error) { + failure ??= error; + } + } + if (failure !== undefined) { + throw failure instanceof Error ? failure : new Error(String(failure)); + } + }, + }; +} diff --git a/packages/graph/src/mcp/createServer.ts b/packages/graph/src/mcp/createServer.ts index 74357536..08b6017e 100644 --- a/packages/graph/src/mcp/createServer.ts +++ b/packages/graph/src/mcp/createServer.ts @@ -7,6 +7,7 @@ import { SamchonGraphApplication, } from "../application"; import { ISamchonGraphApplication } from "../structures"; +import { SamchonRepositoryContextMemory } from "../repository"; import { GraphLanguage } from "../typings"; import { languageDisplayNameOf } from "./languageDisplayNameOf"; @@ -32,6 +33,9 @@ export function createServer( graph: AsyncSamchonGraphSource, version: string, languages: readonly GraphLanguage[] = [], + topology?: () => + | SamchonRepositoryContextMemory + | Promise, ): McpServer { const controller: ILlmController = { protocol: "class", @@ -40,7 +44,7 @@ export function createServer( typia.llm.application(), languageDisplayNameOf(languages), ), - execute: new SamchonGraphApplication(graph), + execute: new SamchonGraphApplication(graph, topology), }; return createMcpServer(controller, { version }); } diff --git a/packages/graph/src/mcp/startServer.ts b/packages/graph/src/mcp/startServer.ts index a7e983a5..46b65ac2 100644 --- a/packages/graph/src/mcp/startServer.ts +++ b/packages/graph/src/mcp/startServer.ts @@ -11,6 +11,11 @@ import { parseGraphDump } from "../indexer/parseGraphDump"; import { SamchonGraphMemory } from "../SamchonGraphMemory"; import { SamchonGraphSourceReader } from "../SamchonGraphSourceReader"; import { GraphLanguage } from "../typings"; +import { + createResidentRepositoryContextMemorySource, + createResidentRepositoryContextSource, +} from "../repository"; +import { createCompositeResidentClose } from "./createCompositeResidentClose"; import { createResidentCloseHandler } from "./createResidentCloseHandler"; import { createResidentGraphMemorySource } from "./createResidentGraphMemorySource"; import { createServer } from "./createServer"; @@ -54,7 +59,15 @@ export async function startServer( ); languages = dump.languages; source = once(() => - SamchonGraphMemory.from(dump, SamchonGraphSourceReader.none(dump.project)), + // A graph file proves the generation it was built from, but this static + // server never revalidates that token against the current checkout. + // Preserve the graph facts while withholding cross-plane compatibility: + // otherwise a current topology model could join to arbitrarily stale + // code merely because two reads returned the same memoized object. + SamchonGraphMemory.from( + { ...dump, generation: undefined }, + SamchonGraphSourceReader.none(dump.project), + ), ); } else { const root = path.resolve(options.cwd ?? process.cwd()); @@ -65,7 +78,15 @@ export async function startServer( resident = opened; source = createResidentGraphMemorySource(opened); } - const server = createServer(source, options.version, languages); + const topologyResident = createResidentRepositoryContextSource( + options.graphFile === undefined + ? path.resolve(options.cwd ?? process.cwd()) + : (await source()).project, + ); + const topology = createResidentRepositoryContextMemorySource( + topologyResident, + ); + const server = createServer(source, options.version, languages, topology); const transport = new StdioServerTransport(); // The resident source holds a live language-server process per language, and // nothing else is going to end them: a client that disconnects closes the @@ -73,7 +94,9 @@ export async function startServer( // goes with it — an orphaned language server outliving the MCP server that // spawned it would hold the process's event loop open and keep a whole Gradle // or solution load resident behind a session nobody is talking to. - const close = createResidentCloseHandler(resident); + const close = createResidentCloseHandler( + createCompositeResidentClose([resident, topologyResident]), + ); // These two bodies run only when the MCP transport is torn down gracefully -- // a client that closes the transport, or a client exit that ends our stdin. // The deterministic harness disconnects by killing the spawned server diff --git a/packages/graph/src/operations/graphTrust.ts b/packages/graph/src/operations/graphTrust.ts new file mode 100644 index 00000000..fb67f694 --- /dev/null +++ b/packages/graph/src/operations/graphTrust.ts @@ -0,0 +1,95 @@ +import { SamchonGraphMemory } from "../SamchonGraphMemory"; +import { + ISamchonGraphApplication, + ISamchonGraphCoverageSummary, + ISamchonGraphUnresolvedSummary, +} from "../structures"; +import { GRAPH_EDGE_KINDS, GraphEdgeKind } from "../typings"; +import { isStructural } from "./isStructural"; + +const LOOKUP_FAMILIES = GRAPH_EDGE_KINDS.filter( + (family) => family === "exports" || !isStructural(family), +); + +/** Structured trust envelope for one non-escape operation. */ +export function graphTrust( + graph: SamchonGraphMemory, + type: Exclude< + ISamchonGraphApplication.IProps["request"]["type"], + "escape" | "topology" + >, +): { + provenance?: ISamchonGraphApplication.IOutput["provenance"]; + coverage: ISamchonGraphCoverageSummary; + unresolved: ISamchonGraphUnresolvedSummary; +} { + const families = familiesOf(type); + const relevant = new Set(families); + const sites = graph.unresolved.filter((row) => relevant.has(row.family)); + const reasonCounts = new Map< + ISamchonGraphUnresolvedSummary["reasons"][number]["reason"], + number + >(); + for (const site of sites) + reasonCounts.set(site.reason, (reasonCounts.get(site.reason) ?? 0) + 1); + return { + ...(graph.provenance.length > 0 + ? { provenance: graph.provenance.map((row) => cloneProvenance(row)) } + : {}), + coverage: { + schemaVersion: 1, + families, + rows: graph.coverage + .filter((row) => relevant.has(row.family)) + .map((row) => ({ ...row })), + }, + unresolved: { + count: sites.length, + reasons: [...reasonCounts] + .sort(([left], [right]) => compareText(left, right)) + .map(([reason, count]) => ({ reason, count })), + examples: sites.slice(0, 20).map((site) => ({ + ...site, + evidence: { ...site.evidence }, + ...(site.candidates !== undefined + ? { candidates: [...site.candidates] } + : {}), + })), + }, + }; +} + +function familiesOf( + type: Exclude< + ISamchonGraphApplication.IProps["request"]["type"], + "escape" | "topology" + >, +): GraphEdgeKind[] { + switch (type) { + case "entrypoints": + case "lookup": + return [...LOOKUP_FAMILIES]; + case "overview": + case "trace": + case "details": + case "tour": + return [...GRAPH_EDGE_KINDS]; + } +} + +function cloneProvenance( + row: NonNullable[number], +): NonNullable[number] { + return { + ...row, + languages: [...row.languages], + facts: [...row.facts], + capabilities: [...row.capabilities], + producer: { ...row.producer }, + }; +} + +function compareText(left: string, right: string): number { + /* c8 ignore next 2 -- reason keys are distinct. */ + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/graph/src/provider/GRAPH_PROVIDERS.ts b/packages/graph/src/provider/GRAPH_PROVIDERS.ts index 54c0dcf2..ac805a36 100644 --- a/packages/graph/src/provider/GRAPH_PROVIDERS.ts +++ b/packages/graph/src/provider/GRAPH_PROVIDERS.ts @@ -1,7 +1,8 @@ import { IGraphProvider } from "./IGraphProvider"; +import { cppGraphProvider } from "./cpp/cppGraphProvider"; import { goGraphProvider } from "./go/goGraphProvider"; import { luaGraphProvider } from "./lua/luaGraphProvider"; -import { rustScipProvider } from "./rust/rustScipProvider"; +import { rustGraphProvider } from "./rust/rustGraphProvider"; import { standardScipProviders } from "./scip/standardScipProviders"; import { standardSidecarProviders } from "./sidecar/standardSidecarProviders"; import { ttscGraphProvider } from "./ttscgraph/ttscGraphProvider"; @@ -25,7 +26,10 @@ export const GRAPH_PROVIDERS: readonly IGraphProvider[] = [ ttscGraphProvider, goGraphProvider, luaGraphProvider, - rustScipProvider, - ...standardScipProviders, + rustGraphProvider, + cppGraphProvider, + ...standardScipProviders.filter( + (provider) => provider.name !== "scip-clang", + ), ...standardSidecarProviders, ]; diff --git a/packages/graph/src/provider/GraphSnapshotProtocol.ts b/packages/graph/src/provider/GraphSnapshotProtocol.ts new file mode 100644 index 00000000..b932320d --- /dev/null +++ b/packages/graph/src/provider/GraphSnapshotProtocol.ts @@ -0,0 +1,881 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; + +import { + ISamchonGraphCoverage, + ISamchonGraphDiagnostic, + ISamchonGraphEdge, + ISamchonGraphNode, + ISamchonGraphUnresolved, +} from "../structures"; +import { + GRAPH_EDGE_KINDS, + GraphEdgeKind, + GraphLanguage, + GraphProviderAuthority, +} from "../typings"; +import { freezeDeep } from "../utils/freezeDeep"; +import { sealedMap } from "../utils/sealedMap"; +import { assertGraphSnapshotPayload } from "./assertGraphSnapshotPayload"; +import { IBulkGraphSession } from "./IBulkGraphSession"; + +/** + * Versioned NDJSON producer contract for atomic, shard-based graph snapshots. + * + * A caller collects one complete frame transaction and applies it at once. + * There is deliberately no partially visible state: parsing, base checks, + * shard digests, coverage, endpoint closure and the final fact digest all pass + * before `current` changes. + */ +export namespace GraphSnapshotProtocol { + export const VERSION = 1; + export const SCHEMA_VERSION = 1; + + const LANGUAGES = new Set([ + "typescript", + "go", + "rust", + "cpp", + "c", + "java", + "csharp", + "kotlin", + "swift", + "scala", + "zig", + "python", + "ruby", + "php", + "lua", + "dart", + "unknown", + ]); + const AUTHORITIES = new Set([ + "compiler", + "analyzer", + "semantic-index", + "navigation", + "heuristic", + ]); + const FACTS = new Set(GRAPH_EDGE_KINDS); + const COVERAGE_STATES = new Set([ + "complete", + "partial", + "unsupported", + ]); + const UNRESOLVED_REASONS = new Set([ + "dynamic", + "reflection", + "macro-or-generated", + "conditional-build", + "external-boundary", + "analysis-error", + "excluded-input", + "identity-unstable", + "provider-gap", + ]); + + export interface IHello { + type: "hello"; + protocolVersion: 1; + schemaVersion: 1; + /** Schema version of the producer payload normalized into this protocol. */ + producerSchemaVersion: number; + provider: string; + producer: string; + producerVersion: string; + compilerVersion: string; + languages: GraphLanguage[]; + authority: GraphProviderAuthority; + supportedFacts: GraphEdgeKind[]; + capabilities: string[]; + } + + export interface IBegin { + type: "begin"; + sequence: number; + generation: string; + baseSequence?: number; + baseGeneration?: string; + universe: string; + manifest: string; + targets: string[]; + } + + export interface ISource { + file: string; + checkerDigest: string; + diskDigest: string; + } + + export interface IShard { + key: string; + target: string; + languages: GraphLanguage[]; + nodes: ISamchonGraphNode[]; + edges: ISamchonGraphEdge[]; + diagnostics: ISamchonGraphDiagnostic[]; + coverage: ISamchonGraphCoverage[]; + unresolved: ISamchonGraphUnresolved[]; + sources: ISource[]; + } + + export interface IUpsertShard { + type: "upsertShard"; + digest: string; + shard: IShard; + } + + export interface IDeleteShard { + type: "deleteShard"; + key: string; + } + + export interface ICommit { + type: "commit"; + sequence: number; + generation: string; + shards: IBulkGraphSession.IShard[]; + factDigest: string; + } + + export type Frame = + | IHello + | IBegin + | IUpsertShard + | IDeleteShard + | ICommit; + + /** SHA-256 over the canonical content of one shard. */ + export function shardDigest(shard: IShard): string { + return digest(shard); + } + + /** + * SHA-256 over the ordered input-file manifest carried by the shards. + * + * Producers include source, configuration, generated and dependency inputs + * here. The store recomputes this digest from the reconstructed generation, + * so `begin.manifest` is evidence rather than an unchecked producer label. + */ + export function manifestDigest(sources: readonly ISource[]): string { + const unique = new Map(); + for (const source of sources) { + const prior = unique.get(source.file); + if ( + prior !== undefined && + (prior.checkerDigest !== source.checkerDigest || + prior.diskDigest !== source.diskDigest) + ) { + throw new Error( + `graph snapshot protocol: input manifest disagrees about source ${source.file}`, + ); + } + unique.set(source.file, source); + } + return digest( + [...unique.values()] + .sort((left, right) => compareText(left.file, right.file)) + .map((source) => ({ ...source })), + ); + } + + /** + * SHA-256 over the complete reconstructed fact payload. + * + * Producer and consumer call this same function; a commit cannot substitute a + * manifest whose shards happen to parse but reconstruct different facts. + */ + export function factDigest(snapshot: Pick< + IBulkGraphSession.ISnapshot, + | "languages" + | "nodes" + | "edges" + | "diagnostics" + | "coverage" + | "unresolved" + | "provenance" + >): string { + return digest({ + languages: snapshot.languages, + nodes: snapshot.nodes, + edges: snapshot.edges, + diagnostics: snapshot.diagnostics, + coverage: snapshot.coverage ?? [], + unresolved: snapshot.unresolved ?? [], + provenance: snapshot.provenance, + }); + } + + /** Parse a complete NDJSON transaction without accepting blank frames. */ + export function parse(text: string): Frame[] { + if (text === "") throw new Error("graph snapshot protocol: empty stream"); + return text.split(/\r?\n/u).map((line, index) => { + if (line === "") { + throw new Error( + `graph snapshot protocol: empty frame at line ${String(index + 1)}`, + ); + } + try { + return JSON.parse(line) as Frame; + } catch { + throw new Error( + `graph snapshot protocol: malformed JSON at line ${String(index + 1)}`, + ); + } + }); + } + + function digest(value: unknown): string { + return createHash("sha256").update(canonical(value)).digest("hex"); + } + + function canonical(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) + return `[${value.map((entry) => canonical(entry)).join(",")}]`; + const object = value as Record; + return `{${Object.keys(object) + .sort(compareText) + .map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`) + .join(",")}}`; + } + + function compareText(left: string, right: string): number { + /* c8 ignore next -- canonical object keys and shard keys are distinct. */ + return left < right ? -1 : left > right ? 1 : 0; + } + + function sameList( + left: readonly string[], + right: readonly string[], + ): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); + } + + /** + * Atomic shard store for one provider. + * + * Failed transactions throw without modifying `current`, `generation`, or + * the committed shard set. + */ + export class Store { + private committed = new Map(); + private identity: IHello | undefined; + private readonly root: string; + private snapshot: IBulkGraphSession.ISnapshot | undefined; + + /** Project root used to bind relative fact evidence to source digests. */ + public constructor(root: string) { + this.root = path.resolve(root); + } + + public get current(): IBulkGraphSession.ISnapshot | undefined { + return this.snapshot; + } + + public apply( + frames: readonly Frame[], + options: { + signal?: AbortSignal; + warnings?: readonly string[]; + validate?: (snapshot: IBulkGraphSession.ISnapshot) => void; + } = {}, + ): IBulkGraphSession.ISnapshot { + throwIfAborted(options.signal); + if (frames.length < 3) { + throw new Error("graph snapshot protocol: incomplete transaction"); + } + const hello = frames[0]; + const begin = frames[1]; + const commit = frames.at(-1); + if (hello?.type !== "hello") { + throw new Error("graph snapshot protocol: transaction must start with hello"); + } + if (begin?.type !== "begin") { + throw new Error("graph snapshot protocol: hello must be followed by begin"); + } + if (commit?.type !== "commit") { + throw new Error("graph snapshot protocol: transaction must end with commit"); + } + assertHello(hello); + assertBegin(begin); + if ( + commit.sequence !== begin.sequence || + commit.generation !== begin.generation + ) { + throw new Error( + "graph snapshot protocol: commit generation does not match begin", + ); + } + const priorGeneration = this.snapshot?.protocol?.generation; + const priorSequence = this.snapshot?.protocol?.sequence; + if ( + begin.baseGeneration !== undefined && + (begin.baseSequence !== priorSequence || + begin.baseGeneration !== priorGeneration) + ) { + throw new Error("graph snapshot protocol: stale base generation"); + } + if (priorSequence !== undefined && begin.sequence <= priorSequence) { + throw new Error( + "graph snapshot protocol: generation sequence did not advance", + ); + } + if ( + begin.baseGeneration !== undefined && + this.identity !== undefined && + !sameIdentity(this.identity, hello) + ) { + throw new Error( + "graph snapshot protocol: producer identity changed across a delta", + ); + } + + const next = + begin.baseGeneration === undefined + ? new Map() + : new Map(this.committed); + const touched = new Set(); + const invalidated = new Set(); + for (const frame of frames.slice(2, -1)) { + throwIfAborted(options.signal); + if (frame.type === "upsertShard") { + if (touched.has(frame.shard.key)) { + throw new Error( + `graph snapshot protocol: duplicate shard delta: ${frame.shard.key}`, + ); + } + touched.add(frame.shard.key); + assertShard(frame.shard, hello, begin); + const digest = shardDigest(frame.shard); + if (frame.digest !== digest) { + throw new Error( + `graph snapshot protocol: shard digest mismatch: ${frame.shard.key}`, + ); + } + if (this.committed.get(frame.shard.key)?.digest !== digest) { + invalidated.add(frame.shard.key); + } + next.set(frame.shard.key, { + digest, + shard: clone(frame.shard), + }); + } else if (frame.type === "deleteShard") { + assertString(frame.key, "deleteShard.key"); + if (touched.has(frame.key)) { + throw new Error( + `graph snapshot protocol: duplicate shard delta: ${frame.key}`, + ); + } + touched.add(frame.key); + if (!next.delete(frame.key)) { + throw new Error( + `graph snapshot protocol: deleted shard does not exist: ${frame.key}`, + ); + } + invalidated.add(frame.key); + } else { + throw new Error( + `graph snapshot protocol: unexpected ${frame.type} inside transaction`, + ); + } + } + if ( + begin.baseGeneration !== undefined && + this.snapshot !== undefined && + begin.manifest !== this.snapshot.protocol!.manifest && + invalidated.size === 0 + ) { + throw new Error( + "graph snapshot protocol: manifest movement reported no shard delta", + ); + } + if ( + begin.baseGeneration !== undefined && + this.snapshot !== undefined && + (begin.universe !== this.snapshot.provenance.universe || + !sameList(begin.targets, this.snapshot.protocol!.targets)) + ) { + const retained = [...this.committed.keys()].find( + (key) => !invalidated.has(key), + ); + if (retained !== undefined) { + throw new Error( + `graph snapshot protocol: universe or target movement retained shard ${retained}`, + ); + } + } + + const expectedManifest = [...next] + .sort(([left], [right]) => compareText(left, right)) + .map(([key, value]) => ({ key, digest: value.digest })); + if (!equalManifest(commit.shards, expectedManifest)) { + throw new Error("graph snapshot protocol: commit shard manifest mismatch"); + } + const assembled = assemble( + hello, + begin, + commit, + expectedManifest, + next, + options.warnings ?? [], + ); + assertAssembledFacts(assembled, hello); + if ( + manifestDigest( + [...assembled.sources].map(([file, source]) => ({ + file, + checkerDigest: source.checkerDigest, + diskDigest: source.diskDigest, + })), + ) !== begin.manifest + ) { + throw new Error( + "graph snapshot protocol: input manifest digest mismatch", + ); + } + if (factDigest(assembled) !== commit.factDigest) { + throw new Error("graph snapshot protocol: commit fact digest mismatch"); + } + assertCompleteCoverage(assembled, hello, begin); + assertGraphSnapshotPayload( + assembled, + this.root, + `graph snapshot protocol: provider "${hello.provider}"`, + ); + throwIfAborted(options.signal); + freezeDeep(assembled, "the graph snapshot protocol generation"); + options.validate?.(assembled); + throwIfAborted(options.signal); + this.committed = next; + this.identity = clone(hello); + this.snapshot = assembled; + return assembled; + } + } + + interface ICommittedShard { + digest: string; + shard: IShard; + } + + function assemble( + hello: IHello, + begin: IBegin, + commit: ICommit, + manifest: IBulkGraphSession.IShard[], + shards: ReadonlyMap, + warnings: readonly string[], + ): IBulkGraphSession.ISnapshot { + const nodes: ISamchonGraphNode[] = []; + const edges: ISamchonGraphEdge[] = []; + const diagnostics: ISamchonGraphDiagnostic[] = []; + const coverage: ISamchonGraphCoverage[] = []; + const unresolved: ISamchonGraphUnresolved[] = []; + const sources = new Map(); + for (const entry of manifest) { + const shard = shards.get(entry.key)!.shard; + nodes.push(...shard.nodes); + edges.push(...shard.edges); + diagnostics.push(...shard.diagnostics); + coverage.push(...shard.coverage); + unresolved.push(...shard.unresolved); + for (const source of shard.sources) { + const value = { + checkerDigest: source.checkerDigest, + diskDigest: source.diskDigest, + }; + const prior = sources.get(source.file); + if ( + prior !== undefined && + (prior.checkerDigest !== value.checkerDigest || + prior.diskDigest !== value.diskDigest) + ) { + throw new Error( + `graph snapshot protocol: shards disagree about source ${source.file}`, + ); + } + sources.set(source.file, value); + } + } + return { + languages: [...hello.languages], + nodes, + edges, + diagnostics, + sources: sealedMap(sources, "the graph snapshot protocol source manifest"), + provenance: { + provider: hello.provider, + authority: hello.authority, + facts: [...hello.supportedFacts], + schemaVersion: hello.producerSchemaVersion, + tool: hello.producer, + toolVersion: hello.producerVersion, + compilerVersion: hello.compilerVersion, + protocolVersion: hello.protocolVersion, + universe: begin.universe, + capabilities: [...hello.capabilities], + }, + coverage, + unresolved, + protocol: { + version: VERSION, + sequence: begin.sequence, + generation: begin.generation, + ...(begin.baseGeneration !== undefined + ? { + baseSequence: begin.baseSequence, + baseGeneration: begin.baseGeneration, + } + : {}), + manifest: begin.manifest, + targets: [...begin.targets], + shards: manifest.map((entry) => ({ ...entry })), + factDigest: commit.factDigest, + }, + warnings: [...warnings], + }; + } + + function assertHello(hello: IHello): void { + if (hello.protocolVersion !== VERSION) { + throw new Error( + `graph snapshot protocol: unsupported version ${String(hello.protocolVersion)}`, + ); + } + if (hello.schemaVersion !== SCHEMA_VERSION) { + throw new Error( + `graph snapshot protocol: unsupported schema version ${String(hello.schemaVersion)}`, + ); + } + if ( + !Number.isSafeInteger(hello.producerSchemaVersion) || + hello.producerSchemaVersion < 1 + ) { + throw new Error( + "graph snapshot protocol: invalid producer schema version", + ); + } + assertString(hello.provider, "hello.provider"); + assertString(hello.producer, "hello.producer"); + assertString(hello.producerVersion, "hello.producerVersion"); + assertString(hello.compilerVersion, "hello.compilerVersion"); + assertUnique(hello.languages, "hello.languages"); + if ( + hello.languages.length === 0 || + hello.languages.some((language) => !LANGUAGES.has(language)) + ) { + throw new Error("graph snapshot protocol: hello languages are invalid"); + } + assertUnique(hello.supportedFacts, "hello.supportedFacts"); + if (hello.supportedFacts.some((fact) => !FACTS.has(fact))) { + throw new Error("graph snapshot protocol: hello facts are invalid"); + } + if (!AUTHORITIES.has(hello.authority)) { + throw new Error("graph snapshot protocol: hello authority is invalid"); + } + assertUnique(hello.capabilities, "hello.capabilities"); + if (hello.capabilities.some((capability) => capability === "")) { + throw new Error("graph snapshot protocol: hello capabilities are invalid"); + } + } + + function assertBegin(begin: IBegin): void { + if (!Number.isSafeInteger(begin.sequence) || begin.sequence < 1) { + throw new Error("graph snapshot protocol: invalid begin.sequence"); + } + assertString(begin.generation, "begin.generation"); + if ( + (begin.baseSequence === undefined) !== + (begin.baseGeneration === undefined) + ) { + throw new Error( + "graph snapshot protocol: base sequence and generation must appear together", + ); + } + if (begin.baseGeneration !== undefined) { + if ( + !Number.isSafeInteger(begin.baseSequence) || + begin.baseSequence! < 1 || + begin.baseSequence! >= begin.sequence + ) { + throw new Error("graph snapshot protocol: invalid begin.baseSequence"); + } + assertString(begin.baseGeneration, "begin.baseGeneration"); + } + assertDigest(begin.universe, "begin.universe"); + assertDigest(begin.manifest, "begin.manifest"); + assertUnique(begin.targets, "begin.targets"); + for (const target of begin.targets) assertString(target, "begin.targets"); + if (begin.targets.length === 0) { + throw new Error("graph snapshot protocol: begin targets are empty"); + } + } + + function assertShard(shard: IShard, hello: IHello, begin: IBegin): void { + assertString(shard.key, "shard.key"); + if (!begin.targets.includes(shard.target)) { + throw new Error( + `graph snapshot protocol: shard ${shard.key} has an unknown target`, + ); + } + assertUnique(shard.languages, `shard ${shard.key} languages`); + if ( + shard.languages.length === 0 || + shard.languages.some((language) => !hello.languages.includes(language)) + ) { + throw new Error( + `graph snapshot protocol: shard ${shard.key} has invalid languages`, + ); + } + const nodeIds = new Set(); + for (const node of shard.nodes) { + if (nodeIds.has(node.id)) { + throw new Error( + `graph snapshot protocol: shard ${shard.key} duplicated node ${node.id}`, + ); + } + nodeIds.add(node.id); + if (!shard.languages.includes(node.language)) { + throw new Error( + `graph snapshot protocol: shard ${shard.key} published a foreign-language node`, + ); + } + } + const edgeKeys = new Set(); + for (const edge of shard.edges) { + const key = `${edge.kind}\0${edge.from}\0${edge.to}`; + if (edgeKeys.has(key)) { + throw new Error( + `graph snapshot protocol: shard ${shard.key} duplicated edge ${key}`, + ); + } + edgeKeys.add(key); + } + const sourceFiles = new Set(); + for (const source of shard.sources) { + assertString(source.file, `shard ${shard.key} source file`); + if (!isCanonicalSource(source.file)) { + throw new Error( + `graph snapshot protocol: shard ${shard.key} has a non-canonical source identity`, + ); + } + if (sourceFiles.has(source.file)) { + throw new Error( + `graph snapshot protocol: shard ${shard.key} duplicated source ${source.file}`, + ); + } + sourceFiles.add(source.file); + assertDigest( + source.checkerDigest, + `shard ${shard.key} source checker digest`, + ); + if (source.diskDigest !== "") { + assertDigest( + source.diskDigest, + `shard ${shard.key} source disk digest`, + ); + } + } + } + + function assertCompleteCoverage( + snapshot: IBulkGraphSession.ISnapshot, + hello: IHello, + begin: IBegin, + ): void { + const rows = new Map(); + for (const row of snapshot.coverage!) { + if ( + row.provider !== hello.provider || + !hello.languages.includes(row.language) || + !begin.targets.includes(row.target) || + !FACTS.has(row.family) || + !COVERAGE_STATES.has(row.state) + ) { + throw new Error("graph snapshot protocol: coverage row has foreign ownership"); + } + const key = coverageKey(row); + if (rows.has(key)) { + throw new Error(`graph snapshot protocol: duplicate coverage row ${key}`); + } + rows.set(key, row); + } + for (const target of begin.targets) + for (const language of hello.languages) + for (const family of GRAPH_EDGE_KINDS) { + const key = coverageKey({ + provider: hello.provider, + language, + target, + family, + }); + const row = rows.get(key); + if (row === undefined) { + throw new Error(`graph snapshot protocol: missing coverage row ${key}`); + } + if ( + row.state !== "unsupported" && + !hello.supportedFacts.includes(family) + ) { + throw new Error( + `graph snapshot protocol: unadvertised family is not unsupported: ${key}`, + ); + } + } + const unresolvedKeys = new Set(); + const unresolvedCoverage = new Set(); + for (const site of snapshot.unresolved!) { + if (!UNRESOLVED_REASONS.has(site.reason)) { + throw new Error( + "graph snapshot protocol: unresolved site has an invalid reason", + ); + } + assertUnique(site.candidates ?? [], "unresolved candidates"); + if (site.universe !== begin.universe) { + throw new Error( + "graph snapshot protocol: unresolved site has a foreign universe", + ); + } + const row = rows.get( + coverageKey({ + provider: site.provider, + language: site.language, + target: site.target, + family: site.family, + }), + ); + if (row?.state !== "partial") { + throw new Error( + "graph snapshot protocol: unresolved site lacks partial coverage", + ); + } + unresolvedCoverage.add(coverageKey(site)); + const key = canonical(site); + if (unresolvedKeys.has(key)) { + throw new Error("graph snapshot protocol: duplicate unresolved site"); + } + unresolvedKeys.add(key); + } + for (const [key, row] of rows) { + if (row.state === "partial" && !unresolvedCoverage.has(key)) { + throw new Error( + `graph snapshot protocol: partial coverage lacks unresolved evidence: ${key}`, + ); + } + } + } + + function assertAssembledFacts( + snapshot: IBulkGraphSession.ISnapshot, + hello: IHello, + ): void { + const nodeIds = new Set(); + const files = new Set(snapshot.sources.keys()); + for (const node of snapshot.nodes) { + if (nodeIds.has(node.id)) { + throw new Error( + `graph snapshot protocol: duplicate assembled node ${node.id}`, + ); + } + nodeIds.add(node.id); + if (node.file !== "") files.add(node.file); + } + const edgeKeys = new Set(); + for (const edge of snapshot.edges) { + if (!hello.supportedFacts.includes(edge.kind)) { + throw new Error( + `graph snapshot protocol: assembled edge uses unadvertised family ${String(edge.kind)}`, + ); + } + const key = `${edge.kind}\0${edge.from}\0${edge.to}`; + if (edgeKeys.has(key)) { + throw new Error( + `graph snapshot protocol: duplicate assembled edge ${key}`, + ); + } + edgeKeys.add(key); + if ( + (!nodeIds.has(edge.from) && !files.has(edge.from)) || + (!nodeIds.has(edge.to) && !files.has(edge.to)) + ) { + throw new Error( + `graph snapshot protocol: assembled edge has an absent endpoint: ${edge.from} -> ${edge.to}`, + ); + } + } + } + + function coverageKey(row: Pick< + ISamchonGraphCoverage, + "provider" | "language" | "target" | "family" + >): string { + return `${row.provider}\0${row.language}\0${row.target}\0${row.family}`; + } + + function equalManifest( + left: readonly IBulkGraphSession.IShard[], + right: readonly IBulkGraphSession.IShard[], + ): boolean { + return ( + left.length === right.length && + left.every( + (entry, index) => + entry.key === right[index]?.key && + entry.digest === right[index]?.digest, + ) + ); + } + + function sameIdentity(left: IHello, right: IHello): boolean { + return canonical(left) === canonical(right); + } + + function assertUnique(values: readonly T[], label: string): void { + if (new Set(values).size !== values.length) { + throw new Error(`graph snapshot protocol: ${label} contains duplicates`); + } + } + + function assertString(value: string, label: string): void { + if (value === "" || value.includes("\0")) { + throw new Error(`graph snapshot protocol: invalid ${label}`); + } + } + + function assertDigest(value: string, label: string): void { + if (!/^[a-f0-9]{64}$/u.test(value)) { + throw new Error(`graph snapshot protocol: invalid ${label}`); + } + } + + function isCanonicalSource(file: string): boolean { + if (!file.startsWith("bundled:///")) { + return path.isAbsolute(file) && path.normalize(file) === file; + } + const relative = file.slice("bundled:///".length); + return ( + relative !== "" && + !relative.includes("\\") && + path.posix.normalize(relative) === relative && + relative + .split("/") + .every((part) => part !== "" && part !== "." && part !== "..") + ); + } + + function clone(value: T): T { + return structuredClone(value); + } + + function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted !== true) return; + const error = new Error("graph snapshot protocol: transaction was aborted"); + error.name = "AbortError"; + throw error; + } +} diff --git a/packages/graph/src/provider/IBulkGraphSession.ts b/packages/graph/src/provider/IBulkGraphSession.ts index 79e25d54..86b2e7c8 100644 --- a/packages/graph/src/provider/IBulkGraphSession.ts +++ b/packages/graph/src/provider/IBulkGraphSession.ts @@ -1,7 +1,9 @@ import { + ISamchonGraphCoverage, ISamchonGraphDiagnostic, ISamchonGraphEdge, ISamchonGraphNode, + ISamchonGraphUnresolved, } from "../structures"; import { GraphEdgeKind, @@ -102,9 +104,52 @@ export namespace IBulkGraphSession { /** Which program produced everything above, and what it can prove. */ provenance: IProvenance; + /** + * Exhaustive completeness rows for protocol-aware producers. + * + * Optional only while legacy strict producers migrate to Graph Snapshot + * Protocol v1. The coordinator normalizes an explicit partial/unsupported + * matrix for those producers so no current dump interprets missing edges as + * semantic absence. + */ + coverage?: ISamchonGraphCoverage[]; + + /** Structured unresolved sites published by a protocol-aware producer. */ + unresolved?: ISamchonGraphUnresolved[]; + + /** Validated protocol generation and content-addressed shard manifest. */ + protocol?: IProtocolGeneration; + warnings: string[]; } + /** Public identity of one committed Graph Snapshot Protocol generation. */ + export interface IProtocolGeneration { + version: number; + /** + * Strictly increasing serial for this resident store. + * + * The serial makes a generation identity the bounded pair + * `(sequence, generation)`: stale ABA transactions can be rejected while + * the store retains only the current pair rather than every obsolete token. + */ + sequence: number; + generation: string; + baseSequence?: number; + baseGeneration?: string; + /** Ordered source/configuration/dependency manifest digest. */ + manifest: string; + targets: string[]; + shards: IShard[]; + factDigest: string; + } + + /** One content-addressed shard retained by a committed generation. */ + export interface IShard { + key: string; + digest: string; + } + /** * The manifest entry for one file in the snapshot's program. * diff --git a/packages/graph/src/provider/IGraphProvider.ts b/packages/graph/src/provider/IGraphProvider.ts index 17e1026f..ada62ed0 100644 --- a/packages/graph/src/provider/IGraphProvider.ts +++ b/packages/graph/src/provider/IGraphProvider.ts @@ -68,6 +68,25 @@ export interface IGraphProvider { */ readonly facts: readonly GraphEdgeKind[]; + /** + * Machine-readable command-selection surface shared with public support + * documentation. + * + * These are resolver inputs, not prose copied out of a closure. Keeping them + * on the registry entry lets CI prove that every documented executable and + * environment override is the one runtime selection actually consults. + */ + readonly resolution?: IGraphProvider.IResolution; + + /** + * Ordered compatibility routes for the same atomic language slice. + * + * These are not additional language owners. The registry still has one + * owner, while selection and the runtime coordinator may step down through + * these routes when a more authoritative producer is absent or fails. + */ + readonly fallbacks?: readonly IGraphProvider[]; + /** * Why this provider cannot serve a build with these options, or `undefined` * when it can. @@ -142,6 +161,17 @@ export interface IGraphProvider { } export namespace IGraphProvider { + export interface IResolution { + readonly commands: readonly string[]; + readonly environmentOverrides: readonly string[]; + /** + * Project-owned files whose contents name additional executables. Those + * commands are dynamic resolver inputs and must not be represented by a + * made-up fixed executable in {@link commands}. + */ + readonly projectCommandSources?: readonly string[]; + } + export interface IConfigurationDerivation { rows: readonly string[]; inconclusive: readonly number[]; diff --git a/packages/graph/src/provider/assertGraphSnapshotContract.ts b/packages/graph/src/provider/assertGraphSnapshotContract.ts index 399feee8..ad444666 100644 --- a/packages/graph/src/provider/assertGraphSnapshotContract.ts +++ b/packages/graph/src/provider/assertGraphSnapshotContract.ts @@ -1,8 +1,8 @@ import path from "node:path"; -import { parseGraphDump } from "../indexer/parseGraphDump"; import { GraphLanguage } from "../typings"; -import { dumpProvenanceOf } from "./dumpProvenanceOf"; +import { assertGraphSnapshotPayload } from "./assertGraphSnapshotPayload"; +import { GraphSnapshotProtocol } from "./GraphSnapshotProtocol"; import { IBulkGraphSession } from "./IBulkGraphSession"; import { IGraphProvider } from "./IGraphProvider"; @@ -30,17 +30,7 @@ export function assertGraphSnapshotContract( ): void { const label = `@samchon/graph: provider "${provider.name}"`; const project = path.resolve(root); - assertProvenance(snapshot, label); - parseGraphDump({ - project, - languages: snapshot.languages, - indexer: "lsp", - nodes: snapshot.nodes, - edges: snapshot.edges, - diagnostics: snapshot.diagnostics, - warnings: snapshot.warnings, - provenance: [dumpProvenanceOf(snapshot)], - }); + assertGraphSnapshotPayload(snapshot, project, label); const claimed = new Set(languages); for (const language of snapshot.languages) { if (!claimed.has(language)) { @@ -94,112 +84,58 @@ export function assertGraphSnapshotContract( ); } - assertSourceManifest(snapshot, project, label, files); + assertProtocol(snapshot, label); } -function assertSourceManifest( +function assertProtocol( snapshot: IBulkGraphSession.ISnapshot, - root: string, label: string, - nodeFiles: ReadonlySet, ): void { - for (const file of snapshot.sources.keys()) { - if (file.startsWith("bundled:///")) { - const relative = file.slice("bundled:///".length); - if ( - relative === "" || - relative.includes("\\") || - path.posix.normalize(relative) !== relative || - relative - .split("/") - .some((part) => part === "" || part === "." || part === "..") - ) { - throw new Error( - `${label} published a non-canonical bundled source identity: ${file}`, - ); - } - } else if (!path.isAbsolute(file) || path.normalize(file) !== file) { - throw new Error( - `${label} published a source identity that is not normalized and absolute: ${file}`, - ); - } - } - - const required = new Set(); - for (const file of nodeFiles) requireHostSource(required, file); - for (const node of snapshot.nodes) { - if (node.evidence?.file !== undefined) { - requireHostSource(required, node.evidence.file); - } - if (node.implementation?.file !== undefined) { - requireHostSource(required, node.implementation.file); - } - } - for (const edge of snapshot.edges) { - if (edge.evidence?.file !== undefined) { - requireHostSource(required, edge.evidence.file); - } - } - for (const diagnostic of snapshot.diagnostics) { - if (diagnostic.file !== "") requireHostSource(required, diagnostic.file); - } - - for (const file of required) { - const source = path.resolve(root, file); - if (!snapshot.sources.has(source)) { - throw new Error( - `${label} published facts for ${file} without binding that file to its source manifest`, - ); - } - } -} - -function requireHostSource(required: Set, file: string): void { - // A bundled identity is versioned with its provider/toolchain and has no - // coordinator-readable host file. Requiring it in the host source manifest - // rejects valid compiler builtins (Go universe nodes, TypeScript lib files) - // without adding a byte fence the coordinator could reproduce. - if (!file.startsWith("bundled:///")) required.add(file); -} - -function assertProvenance( - snapshot: IBulkGraphSession.ISnapshot, - label: string, -): void { - const provenance = snapshot.provenance; + const protocol = snapshot.protocol; + if (protocol === undefined) return; if ( - !Number.isSafeInteger(provenance.schemaVersion) || - provenance.schemaVersion < 1 || - !Number.isSafeInteger(provenance.protocolVersion) || - provenance.protocolVersion < 0 || - provenance.tool === "" || - !SHA256.test(provenance.universe) + protocol.version !== GraphSnapshotProtocol.VERSION || + !Number.isSafeInteger(protocol.sequence) || + protocol.sequence < 1 || + typeof protocol.generation !== "string" || + protocol.generation === "" || + protocol.generation.includes("\0") || + (protocol.baseSequence === undefined) !== + (protocol.baseGeneration === undefined) || + (protocol.baseSequence !== undefined && + (!Number.isSafeInteger(protocol.baseSequence) || + protocol.baseSequence < 1 || + protocol.baseSequence >= protocol.sequence || + typeof protocol.baseGeneration !== "string" || + protocol.baseGeneration === "" || + protocol.baseGeneration.includes("\0"))) || + protocol.targets.length === 0 || + new Set(protocol.targets).size !== protocol.targets.length || + protocol.targets.some( + (target) => + typeof target !== "string" || target === "" || target.includes("\0"), + ) || + !SHA256.test(protocol.manifest) || + !SHA256.test(protocol.factDigest) || + snapshot.coverage === undefined || + snapshot.unresolved === undefined ) { - throw new Error(`${label} published an invalid provenance envelope`); + throw new Error(`${label} published an invalid protocol generation`); } - const capabilities = new Set(provenance.capabilities); - if ( - capabilities.size !== provenance.capabilities.length || - provenance.capabilities.some((capability) => capability === "") || - !capabilities.has("universe") - ) { - throw new Error( - `${label} published duplicate, empty, or unproven provenance capabilities`, - ); - } - const sourceDigests = capabilities.has("sourceDigests"); - const diskDigests = capabilities.has("diskDigests"); - for (const [file, digest] of snapshot.sources) { + const shards = new Set(); + for (const shard of protocol.shards) { if ( - (sourceDigests && !SHA256.test(digest.checkerDigest)) || - (!sourceDigests && digest.checkerDigest !== "") || - (digest.diskDigest !== "" && - (!diskDigests || !SHA256.test(digest.diskDigest))) + shard.key === "" || + shard.key.includes("\0") || + shards.has(shard.key) || + !SHA256.test(shard.digest) ) { - throw new Error( - `${label} published a source digest that contradicts its capabilities: ${file}`, - ); + throw new Error(`${label} published an invalid protocol shard manifest`); } + shards.add(shard.key); + } + if (GraphSnapshotProtocol.factDigest(snapshot) !== protocol.factDigest) { + throw new Error(`${label} published a mismatched protocol fact digest`); } } diff --git a/packages/graph/src/provider/assertGraphSnapshotPayload.ts b/packages/graph/src/provider/assertGraphSnapshotPayload.ts new file mode 100644 index 00000000..68d0bf91 --- /dev/null +++ b/packages/graph/src/provider/assertGraphSnapshotPayload.ts @@ -0,0 +1,174 @@ +import path from "node:path"; + +import { parseGraphDump } from "../indexer/parseGraphDump"; +import { graphSnapshotDigests } from "./graphSnapshotDigests"; +import { IBulkGraphSession } from "./IBulkGraphSession"; + +/** + * Validate the complete semantic payload shared by protocol and legacy + * snapshot publication boundaries. + */ +export function assertGraphSnapshotPayload( + snapshot: IBulkGraphSession.ISnapshot, + root: string, + label: string, +): void { + const project = path.resolve(root); + assertProvenance(snapshot, label); + const provenance = snapshot.provenance; + parseGraphDump({ + project, + languages: snapshot.languages, + indexer: "lsp", + nodes: snapshot.nodes, + edges: snapshot.edges, + diagnostics: snapshot.diagnostics, + warnings: snapshot.warnings, + provenance: [ + { + provider: provenance.provider, + languages: [...snapshot.languages], + authority: provenance.authority, + facts: [...provenance.facts], + capabilities: [...provenance.capabilities], + producer: { + tool: provenance.tool, + version: provenance.toolVersion, + compiler: provenance.compilerVersion, + schemaVersion: provenance.schemaVersion, + protocolVersion: provenance.protocolVersion, + }, + universe: provenance.universe, + manifest: graphSnapshotDigests.manifestOf(snapshot), + content: graphSnapshotDigests.contentOf(snapshot), + }, + ], + ...(snapshot.coverage !== undefined + ? { coverage: snapshot.coverage } + : {}), + ...(snapshot.unresolved !== undefined + ? { unresolved: snapshot.unresolved } + : {}), + }); + + const nodeFiles = new Set(); + for (const node of snapshot.nodes) { + if (node.file !== "") nodeFiles.add(node.file); + } + assertSourceManifest(snapshot, project, label, nodeFiles); +} + +function assertSourceManifest( + snapshot: IBulkGraphSession.ISnapshot, + root: string, + label: string, + nodeFiles: ReadonlySet, +): void { + for (const file of snapshot.sources.keys()) { + if (file.startsWith("bundled:///")) { + const relative = file.slice("bundled:///".length); + if ( + relative === "" || + relative.includes("\0") || + relative.includes("\\") || + path.posix.normalize(relative) !== relative || + relative + .split("/") + .some((part) => part === "" || part === "." || part === "..") + ) { + throw new Error( + `${label} published a non-canonical bundled source identity: ${file}`, + ); + } + } else if ( + file.includes("\0") || + !path.isAbsolute(file) || + path.normalize(file) !== file + ) { + throw new Error( + `${label} published a source identity that is not normalized and absolute: ${file}`, + ); + } + } + + const required = new Set(); + for (const file of nodeFiles) requireHostSource(required, file); + for (const node of snapshot.nodes) { + if (node.evidence?.file !== undefined) { + requireHostSource(required, node.evidence.file); + } + if (node.implementation?.file !== undefined) { + requireHostSource(required, node.implementation.file); + } + } + for (const edge of snapshot.edges) { + if (edge.evidence?.file !== undefined) { + requireHostSource(required, edge.evidence.file); + } + } + for (const diagnostic of snapshot.diagnostics) { + if (diagnostic.file !== "") requireHostSource(required, diagnostic.file); + } + for (const unresolved of snapshot.unresolved ?? []) { + requireHostSource(required, unresolved.evidence.file); + } + + for (const file of required) { + const source = path.resolve(root, file); + if (!snapshot.sources.has(source)) { + throw new Error( + `${label} published facts for ${file} without binding that file to its source manifest`, + ); + } + } +} + +function requireHostSource(required: Set, file: string): void { + // A bundled identity is versioned with its provider/toolchain and has no + // coordinator-readable host file. Requiring it in the host source manifest + // rejects valid compiler builtins without adding a reproducible byte fence. + if (!file.startsWith("bundled:///")) required.add(file); +} + +function assertProvenance( + snapshot: IBulkGraphSession.ISnapshot, + label: string, +): void { + const provenance = snapshot.provenance; + if ( + !Number.isSafeInteger(provenance.schemaVersion) || + provenance.schemaVersion < 1 || + !Number.isSafeInteger(provenance.protocolVersion) || + provenance.protocolVersion < 0 || + provenance.tool === "" || + !SHA256.test(provenance.universe) + ) { + throw new Error(`${label} published an invalid provenance envelope`); + } + const capabilities = new Set(provenance.capabilities); + if ( + capabilities.size !== provenance.capabilities.length || + provenance.capabilities.some((capability) => capability === "") || + !capabilities.has("universe") + ) { + throw new Error( + `${label} published duplicate, empty, or unproven provenance capabilities`, + ); + } + const sourceDigests = capabilities.has("sourceDigests"); + const diskDigests = capabilities.has("diskDigests"); + for (const [file, digest] of snapshot.sources) { + if ( + (sourceDigests && !SHA256.test(digest.checkerDigest)) || + (!sourceDigests && digest.checkerDigest !== "") || + (digest.diskDigest !== "" && + (!diskDigests || !SHA256.test(digest.diskDigest))) + ) { + throw new Error( + `${label} published a source digest that contradicts its capabilities: ${file}`, + ); + } + } +} + +const SHA256 = /^[0-9a-f]{64}$/; diff --git a/packages/graph/src/provider/coverageRows.ts b/packages/graph/src/provider/coverageRows.ts new file mode 100644 index 00000000..d8014a53 --- /dev/null +++ b/packages/graph/src/provider/coverageRows.ts @@ -0,0 +1,31 @@ +import { ISamchonGraphCoverage } from "../structures"; +import { + GRAPH_EDGE_KINDS, + GraphEdgeKind, + GraphLanguage, +} from "../typings"; + +/** Build one deterministic exhaustive coverage matrix. */ +export function coverageRows( + provider: string, + languages: readonly GraphLanguage[], + target: string, + supported: ReadonlySet, +): ISamchonGraphCoverage[] { + return [...languages] + .sort(compareText) + .flatMap((language) => + GRAPH_EDGE_KINDS.map((family) => ({ + provider, + language, + target, + family, + state: supported.has(family) ? "partial" : "unsupported", + })), + ); +} + +function compareText(left: string, right: string): number { + /* c8 ignore next 2 -- normalized language sets contain distinct values. */ + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/graph/src/provider/cpp/CPP_CLANG_FACTS.ts b/packages/graph/src/provider/cpp/CPP_CLANG_FACTS.ts new file mode 100644 index 00000000..4eaf2971 --- /dev/null +++ b/packages/graph/src/provider/cpp/CPP_CLANG_FACTS.ts @@ -0,0 +1,6 @@ +import { GRAPH_EDGE_KINDS, GraphEdgeKind } from "../../typings"; + +export const CPP_CLANG_FACTS: readonly GraphEdgeKind[] = + GRAPH_EDGE_KINDS.filter( + (kind) => !["decorates", "renders", "tests"].includes(kind), + ); diff --git a/packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts b/packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts new file mode 100644 index 00000000..955bdf49 --- /dev/null +++ b/packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts @@ -0,0 +1,3 @@ +/** Exact samchon/llvm-project producer revision required by this adapter. */ +export const CPP_CLANG_PRODUCER_COMMIT = + "ae904413566b54aca08e46ebee1769c110601e6b"; diff --git a/packages/graph/src/provider/cpp/CPP_CLANG_PROVIDER.ts b/packages/graph/src/provider/cpp/CPP_CLANG_PROVIDER.ts new file mode 100644 index 00000000..be810738 --- /dev/null +++ b/packages/graph/src/provider/cpp/CPP_CLANG_PROVIDER.ts @@ -0,0 +1 @@ +export const CPP_CLANG_PROVIDER = "clangd-snapshot"; diff --git a/packages/graph/src/provider/cpp/CppGraphClient.ts b/packages/graph/src/provider/cpp/CppGraphClient.ts new file mode 100644 index 00000000..11434462 --- /dev/null +++ b/packages/graph/src/provider/cpp/CppGraphClient.ts @@ -0,0 +1,552 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createHash } from "node:crypto"; + +import { compareOrdinal as compareText } from "@samchon/graph-sitter"; + +import { LspClient } from "../../lsp/LspClient"; +import { LspResponseError } from "../../lsp/LspResponseError"; +import { GraphLanguage } from "../../typings"; +import { IBulkGraphSession } from "../IBulkGraphSession"; +import { CppGraphSnapshotAdapter } from "./CppGraphSnapshotAdapter"; +import { ICppGraphSnapshot } from "./ICppGraphSnapshot"; + +const GRAPH_METHOD = "samchon/graphSnapshot"; +const SERVER_CANCELLED = -32802; +const CONTENT_MODIFIED = -32801; +const DEFAULT_READY_TIMEOUT_MS = 300_000; +const RETRY_DELAY_MS = 50; +const MAX_RETRY_DELAY_MS = 5_000; +const PAGE_SHARDS = 32; + +/** Resident LSP client for the pinned clangd graph-snapshot producer. */ +export class CppGraphClient implements IBulkGraphSession { + public readonly kind = "bulk" as const; + public readonly languages: readonly GraphLanguage[]; + public readonly root: string; + + private readonly lsp: LspClient; + private readonly adapter: CppGraphSnapshotAdapter; + private readonly validate: ( + snapshot: IBulkGraphSession.ISnapshot, + ) => void; + private readonly initializationOptions: unknown; + private readonly requestTimeoutMs: number | undefined; + private readonly readyTimeoutMs: number; + private readonly lifecycleAbort = new AbortController(); + private queue: Promise = Promise.resolve(); + private initialized: Promise | undefined; + private watchedInputs = new Map(); + private version = 0; + private closed = false; + private closing: Promise | undefined; + + public constructor(options: CppGraphClient.IOptions) { + this.root = options.root; + this.languages = [...options.languages]; + this.adapter = new CppGraphSnapshotAdapter( + options.root, + options.producerCommit, + options.languages, + ); + this.validate = options.validate ?? (() => undefined); + this.initializationOptions = options.initializationOptions; + this.requestTimeoutMs = options.requestTimeoutMs; + this.readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS; + this.lsp = new LspClient( + options.command, + options.args ?? [], + options.requestTimeoutMs, + options.root, + options.maxMessageBytes, + options.windowsVerbatimArguments, + undefined, + serverRequest, + ); + } + + public get generation(): number { + return this.version; + } + + public get current(): IBulkGraphSession.ISnapshot | undefined { + return this.adapter.store.current; + } + + public refresh( + options: { signal?: AbortSignal } = {}, + ): Promise { + if (this.closed) { + return Promise.reject(new Error("C/C++ clang graph: session is closed")); + } + return this.enqueue(async () => { + const signal = combineSignals(options.signal, this.lifecycleAbort.signal); + await this.initialize(signal); + this.notifyInputChanges(); + const raw = await this.requestSnapshot(signal); + const result = this.adapter.apply(raw, this.validate); + this.commitSnapshotInputs(result.snapshot); + if (!result.changed) { + return { + changed: false, + generation: this.version, + mode: result.mode, + snapshot: result.snapshot, + }; + } + this.version += 1; + return { + changed: true, + generation: this.version, + mode: result.mode, + snapshot: result.snapshot, + }; + }, options.signal); + } + + public close(): Promise { + if (this.closing !== undefined) return this.closing; + this.closed = true; + this.lifecycleAbort.abort(new Error("C/C++ clang graph: session is closed")); + this.closing = this.lsp.close(); + return this.closing; + } + + private initialize(signal: AbortSignal): Promise { + this.initialized ??= this.initializeOnce(this.lifecycleAbort.signal); + return signal === this.lifecycleAbort.signal + ? this.initialized + : raceWithAbort(this.initialized, signal); + } + + private async initializeOnce(signal: AbortSignal): Promise { + await this.lsp.request( + "initialize", + { + processId: process.pid, + rootUri: pathToFileURL(this.root).href, + capabilities: { workspace: { configuration: true } }, + ...(this.initializationOptions === undefined + ? {} + : { initializationOptions: this.initializationOptions }), + workspaceFolders: [ + { + uri: pathToFileURL(this.root).href, + name: "samchon-graph-cpp", + }, + ], + }, + this.requestTimeoutMs, + signal, + ); + this.lsp.notify("initialized", {}); + const inputs = inputDigests(this.root); + const changes = [...inputs] + .filter(([, digest]) => digest !== null) + .map(([file]) => ({ uri: pathToFileURL(file).href, type: 1 })); + if (changes.length !== 0) { + this.lsp.notify("workspace/didChangeWatchedFiles", { changes }); + } + this.watchedInputs = inputs; + } + + private notifyInputChanges(): void { + const current = inputDigests(this.root, this.current); + const files = new Set([...this.watchedInputs.keys(), ...current.keys()]); + const changes: Array<{ uri: string; type: 1 | 2 | 3 }> = []; + for (const file of [...files].sort(compareText)) { + const before = this.watchedInputs.get(file); + const after = current.get(file); + if (before === after) continue; + const type = before === undefined || before === null ? 1 : after === null || after === undefined ? 3 : 2; + changes.push({ uri: pathToFileURL(file).href, type }); + } + if (changes.length !== 0) { + this.lsp.notify("workspace/didChangeWatchedFiles", { changes }); + } + this.watchedInputs = current; + } + + private commitSnapshotInputs(snapshot: IBulkGraphSession.ISnapshot): void { + const committed = inputDigests(this.root, snapshot); + for (const [file, source] of snapshot.sources) { + if (!path.isAbsolute(file)) continue; + committed.set( + file, + source.diskDigest === "" ? null : source.diskDigest, + ); + } + this.watchedInputs = committed; + } + + private async requestSnapshot( + signal: AbortSignal, + ): Promise { + const deadline = performance.now() + this.readyTimeoutMs; + let backoff = RETRY_DELAY_MS; + for (;;) { + throwIfAborted(signal); + try { + return await this.requestSnapshotPages(signal); + } catch (error) { + if ( + !(error instanceof LspResponseError) || + (error.code !== SERVER_CANCELLED && error.code !== CONTENT_MODIFIED) + ) { + throw error; + } + if (error.code === CONTENT_MODIFIED) this.notifyInputChanges(); + if (performance.now() >= deadline) { + throw new Error( + `C/C++ clang graph: producer did not become ready within ${String(this.readyTimeoutMs)} ms: ${error.message}`, + ); + } + // Clamped to what is left, so the wait cannot outlive the bound the + // error message quotes. Sleeping a flat cap from just before the + // deadline would overshoot it by up to that cap, which is a stated + // bound quietly widened — the thing this provider keeps having to + // correct elsewhere. + await delay( + Math.min(backoff, Math.max(0, deadline - performance.now())), + signal, + ); + // Backing off, because polling twenty times a second for a condition + // that takes minutes is wrong on its own terms. Each retry is one + // round trip and one refusal — the producer rejects before assembling + // anything — but at a flat 50 ms that is still about 5,400 of them + // over four and a half minutes, aimed at a process that is indexing + // the whole compilation database. + // + // Four CI runs died there, the host reporting a shutdown 4m23s to + // 4m47s after indexing began, every one of them past the 180-second + // timeout that used to end the wait first. That the polling caused it + // is not established — this is a correlation and nothing here has + // measured the host — but it is the only thing this repository does at + // that cadence, and the change is cheap enough not to need the proof. + // + // Reset for content movement, which is a different condition: the + // inputs changed rather than the producer being busy, `notifyInputChanges` + // has already told it so, and an edit should not wait out a backoff + // that a previous slow index inflated. + backoff = + error.code === CONTENT_MODIFIED + ? RETRY_DELAY_MS + : Math.min(backoff * 2, MAX_RETRY_DELAY_MS); + } + } + } + + private async requestSnapshotPages( + signal: AbortSignal, + ): Promise { + const knownGeneration = this.adapter.generation; + let cursor: string | undefined; + let expectedOffset = 0; + let expectedTotal: number | undefined; + let combined: ICppGraphSnapshot | undefined; + const cursors = new Set(); + for (;;) { + const value = await this.lsp.request( + GRAPH_METHOD, + { + ...(knownGeneration === undefined ? {} : { knownGeneration }), + ...(cursor === undefined ? {} : { cursor }), + maxShards: PAGE_SHARDS, + }, + this.requestTimeoutMs, + signal, + ); + assertSnapshotPage(value, expectedOffset, expectedTotal); + const page = value; + expectedTotal ??= page.page.total; + if (combined === undefined) { + combined = structuredClone(page); + } else { + assertSameGeneration(combined, page); + if (page.manifest.length !== 0 || page.deletes.length !== 0) { + throw new Error( + "C/C++ clang graph: continuation repeated generation metadata", + ); + } + combined.upserts.push(...structuredClone(page.upserts)); + combined.phases.validationMillis += page.phases.validationMillis; + combined.phases.semanticMillis += page.phases.semanticMillis; + combined.phases.shardMillis += page.phases.shardMillis; + combined.phases.encodeMillis += page.phases.encodeMillis; + combined.phases.totalMillis += page.phases.totalMillis; + } + expectedOffset += page.page.count; + if (page.page.nextCursor === null) { + if (expectedOffset !== expectedTotal) { + throw new Error("C/C++ clang graph: paged generation ended early"); + } + combined.page = { + offset: 0, + count: combined.upserts.length, + total: combined.upserts.length, + nextCursor: null, + }; + return combined; + } + if ( + expectedOffset >= expectedTotal || + cursors.has(page.page.nextCursor) + ) { + throw new Error("C/C++ clang graph: invalid continuation cursor"); + } + cursors.add(page.page.nextCursor); + cursor = page.page.nextCursor; + } + } + + private enqueue( + task: () => Promise, + signal?: AbortSignal, + ): Promise { + let resolveResult!: (value: T) => void; + let rejectResult!: (error: Error) => void; + let started = false; + let settled = false; + const result = new Promise((resolve, reject) => { + resolveResult = (value) => { + settled = true; + resolve(value); + }; + rejectResult = (error) => { + settled = true; + reject(error); + }; + }); + const cancelQueued = (): void => { + if (!started) rejectResult(cancelledError(signal)); + }; + if (signal?.aborted) { + rejectResult(cancelledError(signal)); + return result; + } + signal?.addEventListener("abort", cancelQueued, { once: true }); + this.queue = this.queue + .catch(() => undefined) + .then(async () => { + started = true; + signal?.removeEventListener("abort", cancelQueued); + if (settled) return; + try { + resolveResult(await task()); + } catch (error) { + rejectResult(asError(error)); + } + }); + return result; + } +} + +export namespace CppGraphClient { + export interface IOptions { + root: string; + languages: readonly GraphLanguage[]; + command: string; + args?: readonly string[]; + producerCommit: string; + initializationOptions?: unknown; + requestTimeoutMs?: number; + readyTimeoutMs?: number; + maxMessageBytes?: number; + windowsVerbatimArguments?: boolean; + validate?: (snapshot: IBulkGraphSession.ISnapshot) => void; + } +} + +interface ICompileCommand { + directory?: unknown; + file?: unknown; +} + +function compilationDatabaseFiles(root: string): string[] { + for (const candidate of [ + path.join(root, "compile_commands.json"), + path.join(root, "build", "compile_commands.json"), + ]) { + try { + const parsed = JSON.parse(fs.readFileSync(candidate, "utf8")) as unknown; + if (!Array.isArray(parsed)) continue; + const files = new Set(); + for (const row of parsed as ICompileCommand[]) { + if (typeof row.file !== "string" || row.file === "") continue; + const directory = + typeof row.directory === "string" && row.directory !== "" + ? row.directory + : path.dirname(candidate); + files.add( + path.resolve( + path.isAbsolute(row.file) ? root : directory, + row.file, + ), + ); + } + return [...files].sort(compareText); + } catch { + continue; + } + } + return []; +} + +function inputDigests( + root: string, + snapshot?: IBulkGraphSession.ISnapshot, +): Map { + const files = new Set([ + path.join(root, ".clangd"), + path.join(root, "compile_flags.txt"), + path.join(root, "compile_commands.json"), + path.join(root, "build", "compile_commands.json"), + ...compilationDatabaseFiles(root), + ]); + for (const file of snapshot?.sources.keys() ?? []) { + if (path.isAbsolute(file)) files.add(file); + } + return new Map( + [...files] + .sort(compareText) + .map((file) => [file, fileDigest(file)] as const), + ); +} + +function fileDigest(file: string): string | null { + try { + return createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + } catch { + return null; + } +} + +function serverRequest(method: string, params: unknown): unknown { + if (method !== "workspace/configuration") return null; + const items = (params as { items?: unknown })?.items; + return Array.isArray(items) ? items.map(() => null) : []; +} + +function delay(milliseconds: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", abort); + resolve(undefined); + }, milliseconds); + timer.unref?.(); + const abort = (): void => { + clearTimeout(timer); + signal.removeEventListener("abort", abort); + reject(cancelledError(signal)); + }; + signal.addEventListener("abort", abort, { once: true }); + }); +} + +function combineSignals( + caller: AbortSignal | undefined, + lifecycle: AbortSignal, +): AbortSignal { + return caller === undefined ? lifecycle : AbortSignal.any([caller, lifecycle]); +} + +function raceWithAbort(task: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(cancelledError(signal)); + return new Promise((resolve, reject) => { + const abort = (): void => reject(cancelledError(signal)); + signal.addEventListener("abort", abort, { once: true }); + void task + .then((value) => { + signal.removeEventListener("abort", abort); + resolve(value); + }) + .catch((error: unknown) => { + signal.removeEventListener("abort", abort); + reject(error); + }); + }); +} + +function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) throw cancelledError(signal); +} + +function cancelledError(signal?: AbortSignal): Error { + const reason = signal?.reason === undefined ? "" : `: ${String(signal.reason)}`; + const error = new Error(`C/C++ clang graph: snapshot request cancelled${reason}`); + error.name = "AbortError"; + return error; +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function assertSnapshotPage( + value: unknown, + expectedOffset: number, + expectedTotal: number | undefined, +): asserts value is ICppGraphSnapshot { + if ( + value === null || + typeof value !== "object" || + !Array.isArray((value as ICppGraphSnapshot).upserts) || + !Array.isArray((value as ICppGraphSnapshot).deletes) || + !Array.isArray((value as ICppGraphSnapshot).manifest) + ) { + throw new Error("C/C++ clang graph: malformed paged generation"); + } + const snapshot = value as ICppGraphSnapshot; + const page = snapshot.page; + if ( + page === null || + typeof page !== "object" || + !Number.isSafeInteger(page.offset) || + !Number.isSafeInteger(page.count) || + !Number.isSafeInteger(page.total) || + page.offset !== expectedOffset || + page.count !== snapshot.upserts.length || + page.count < 0 || + page.total < page.offset + page.count || + (expectedTotal !== undefined && page.total !== expectedTotal) || + (page.nextCursor !== null && + (typeof page.nextCursor !== "string" || page.nextCursor === "")) || + snapshot.phases === null || + typeof snapshot.phases !== "object" + ) { + throw new Error("C/C++ clang graph: malformed snapshot page envelope"); + } + for (const value of [ + snapshot.phases.validationMillis, + snapshot.phases.semanticMillis, + snapshot.phases.shardMillis, + snapshot.phases.encodeMillis, + snapshot.phases.totalMillis, + ]) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error("C/C++ clang graph: malformed page telemetry"); + } + } + if (typeof snapshot.phases.cacheHit !== "boolean") { + throw new Error("C/C++ clang graph: malformed page cache state"); + } +} + +function assertSameGeneration( + first: ICppGraphSnapshot, + next: ICppGraphSnapshot, +): void { + if ( + first.protocolVersion !== next.protocolVersion || + first.schemaVersion !== next.schemaVersion || + first.sequence !== next.sequence || + first.generation !== next.generation || + first.baseGeneration !== next.baseGeneration || + first.phases.cacheHit !== next.phases.cacheHit || + JSON.stringify(first.producer) !== JSON.stringify(next.producer) || + JSON.stringify(first.universe) !== JSON.stringify(next.universe) + ) { + throw new Error("C/C++ clang graph: continuation crossed generations"); + } +} diff --git a/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts b/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts new file mode 100644 index 00000000..d98373fe --- /dev/null +++ b/packages/graph/src/provider/cpp/CppGraphSnapshotAdapter.ts @@ -0,0 +1,1451 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { compareOrdinal as compareText } from "@samchon/graph-sitter"; + +import { + ISamchonGraphCoverage, + ISamchonGraphDiagnostic, + ISamchonGraphEdge, + ISamchonGraphEvidence, + ISamchonGraphNode, + ISamchonGraphUnresolved, +} from "../../structures"; +import { + GRAPH_EDGE_KINDS, + GraphEdgeKind, + GraphLanguage, + GraphNodeKind, +} from "../../typings"; +import { GraphSnapshotProtocol } from "../GraphSnapshotProtocol"; +import { IBulkGraphSession } from "../IBulkGraphSession"; +import { semanticGraphNodeId } from "../semanticIdentity"; +import { CPP_CLANG_FACTS } from "./CPP_CLANG_FACTS"; +import { CPP_CLANG_PROVIDER } from "./CPP_CLANG_PROVIDER"; +import { ICppGraphSnapshot } from "./ICppGraphSnapshot"; + +const SHA256 = /^[a-f0-9]{64}$/u; +const ROLE = { + declaration: 1 << 0, + definition: 1 << 1, + reference: 1 << 2, + read: 1 << 3, + write: 1 << 4, + call: 1 << 5, + dynamic: 1 << 6, + childOf: 1 << 10, + baseOf: 1 << 11, + overrideOf: 1 << 12, + calledBy: 1 << 14, + extendedBy: 1 << 15, + accessorOf: 1 << 16, + containedBy: 1 << 17, + specializationOf: 1 << 19, + nameReference: 1 << 20, +} as const; +const TYPE_KINDS = new Set([6, 7, 8, 9, 10, 11, 12, 28, 29, 31]); +const CAPABILITIES = [ + "coverage", + "diagnostics", + "diskDigests", + "incremental", + "sourceDigests", + "universe", + "unresolved", +]; + +/** Converts one validated native clangd generation into the common protocol. */ +export class CppGraphSnapshotAdapter { + public readonly store: GraphSnapshotProtocol.Store; + private readonly selectedLanguages: ReadonlySet; + private rawShards = new Map(); + private graphShards = new Map(); + private rawGeneration: string | undefined; + + public constructor( + private readonly root: string, + private readonly producerCommit: string, + languages: readonly GraphLanguage[] = ["c", "cpp"], + ) { + this.store = new GraphSnapshotProtocol.Store(root); + this.selectedLanguages = new Set(languages); + } + + public get generation(): string | undefined { + return this.rawGeneration; + } + + public apply( + raw: ICppGraphSnapshot, + validate: (snapshot: IBulkGraphSession.ISnapshot) => void, + ): CppGraphSnapshotAdapter.IResult { + assertSnapshot(raw, this.producerCommit); + if ( + raw.baseGeneration !== null && + raw.baseGeneration !== this.rawGeneration + ) { + throw new Error("C/C++ clang graph: stale producer base generation"); + } + const prior = this.store.current; + if ( + prior !== undefined && + raw.generation === this.rawGeneration && + raw.baseGeneration === this.rawGeneration && + raw.upserts.length === 0 && + raw.deletes.length === 0 && + raw.manifest.length === 0 && + raw.page.total === 0 && + raw.phases.cacheHit && + raw.universe.digest === prior.provenance.universe + ) { + assertNativeGeneration( + raw, + nativeManifest(this.rawShards), + this.rawShards, + ); + return { changed: false, mode: "unchanged", snapshot: prior }; + } + const nextRaw = + raw.baseGeneration === null + ? new Map() + : new Map(this.rawShards); + const touched = new Set(); + for (const key of raw.deletes) { + if (touched.has(key) || !nextRaw.delete(key)) { + throw new Error(`C/C++ clang graph: invalid delete ${key}`); + } + touched.add(key); + } + for (const shard of raw.upserts) { + assertShard(shard, producerFingerprint(raw.producer)); + if (touched.has(shard.key)) { + throw new Error(`C/C++ clang graph: duplicate delta ${shard.key}`); + } + touched.add(shard.key); + nextRaw.set(shard.key, structuredClone(shard)); + } + const expectedManifest = nativeManifest(nextRaw); + if ( + expectedManifest.length !== raw.manifest.length || + expectedManifest.some( + (entry, index) => + entry.key !== raw.manifest[index]?.key || + entry.digest !== raw.manifest[index]?.digest, + ) + ) { + throw new Error("C/C++ clang graph: producer manifest mismatch"); + } + assertNativeGeneration(raw, expectedManifest, nextRaw); + + const hello = helloOf(raw, nextRaw, this.selectedLanguages); + const languagesChanged = + prior !== undefined && + JSON.stringify(prior.languages) !== JSON.stringify(hello.languages); + const universeChanged = + prior !== undefined && raw.universe.digest !== prior.provenance.universe; + const requiresReload = languagesChanged || universeChanged; + const nextGraph = + raw.baseGeneration === null || requiresReload + ? new Map() + : new Map(this.graphShards); + const graphUpserts = requiresReload ? [...nextRaw.values()] : raw.upserts; + for (const key of raw.deletes) nextGraph.delete(graphKey(key)); + for (const shard of graphUpserts) { + const key = graphKey(shard.key); + const language = shard.graph.language; + if ( + (language !== "c" && language !== "cpp") || + !this.selectedLanguages.has(language) + ) { + nextGraph.delete(key); + continue; + } + nextGraph.set(key, adaptShard(this.root, raw, shard, hello.languages)); + } + const sequence = (prior?.protocol?.sequence ?? 0) + 1; + const manifest = [...nextGraph] + .sort(([left], [right]) => compareText(left, right)) + .map(([key, shard]) => ({ + key, + digest: GraphSnapshotProtocol.shardDigest(shard), + })); + const ordered = manifest.map((entry) => nextGraph.get(entry.key)!); + const targets = [...new Set(ordered.map((shard) => shard.target))].sort( + compareText, + ); + const begin: GraphSnapshotProtocol.IBegin = { + type: "begin", + sequence, + generation: raw.generation, + ...(raw.baseGeneration !== null && prior !== undefined && !requiresReload + ? { + baseSequence: prior.protocol!.sequence, + baseGeneration: prior.protocol!.generation, + } + : {}), + universe: raw.universe.digest, + manifest: GraphSnapshotProtocol.manifestDigest( + ordered.flatMap((shard) => shard.sources), + ), + targets, + }; + const facts = factsOf(hello, begin, ordered); + const commit: GraphSnapshotProtocol.ICommit = { + type: "commit", + sequence, + generation: raw.generation, + shards: manifest, + factDigest: GraphSnapshotProtocol.factDigest(facts), + }; + const frames = framesOf(hello, begin, commit, manifest, nextGraph, prior); + const fullBegin: GraphSnapshotProtocol.IBegin = { + ...begin, + baseSequence: undefined, + baseGeneration: undefined, + }; + const fullFrames: GraphSnapshotProtocol.Frame[] = [hello, fullBegin]; + for (const entry of manifest) { + fullFrames.push({ + type: "upsertShard", + digest: entry.digest, + shard: structuredClone(nextGraph.get(entry.key)!), + }); + } + fullFrames.push(commit); + new GraphSnapshotProtocol.Store(this.root).apply(fullFrames, { validate }); + const snapshot = this.store.apply(frames, { validate }); + this.rawShards = nextRaw; + this.graphShards = nextGraph; + this.rawGeneration = raw.generation; + return { + changed: true, + mode: + prior === undefined + ? "initial" + : begin.baseGeneration === undefined + ? "reload" + : "incremental", + snapshot, + }; + } +} + +export namespace CppGraphSnapshotAdapter { + export interface IResult { + changed: boolean; + mode: IBulkGraphSession.Mode; + snapshot: IBulkGraphSession.ISnapshot; + } +} + +function framesOf( + hello: GraphSnapshotProtocol.IHello, + begin: GraphSnapshotProtocol.IBegin, + commit: GraphSnapshotProtocol.ICommit, + manifest: readonly IBulkGraphSession.IShard[], + shards: ReadonlyMap, + prior: IBulkGraphSession.ISnapshot | undefined, +): GraphSnapshotProtocol.Frame[] { + const frames: GraphSnapshotProtocol.Frame[] = [hello, begin]; + if (begin.baseGeneration === undefined) { + for (const entry of manifest) { + frames.push({ + type: "upsertShard", + digest: entry.digest, + shard: structuredClone(shards.get(entry.key)!), + }); + } + } else { + const old = new Map( + prior!.protocol!.shards.map((entry) => [entry.key, entry.digest]), + ); + for (const entry of manifest) { + if (old.get(entry.key) === entry.digest) continue; + frames.push({ + type: "upsertShard", + digest: entry.digest, + shard: structuredClone(shards.get(entry.key)!), + }); + } + } + frames.push(commit); + return frames; +} + +interface IContext { + root: string; + raw: ICppGraphSnapshot; + shard: ICppGraphSnapshot.IShard; + graph: ICppGraphSnapshot.ITU; + language: GraphLanguage; + target: string; + nodes: Map; + ids: Map; + files: Map; + edges: Map; + unresolved: ISamchonGraphUnresolved[]; +} + +function adaptShard( + root: string, + raw: ICppGraphSnapshot, + shard: ICppGraphSnapshot.IShard, + snapshotLanguages: readonly GraphLanguage[], +): GraphSnapshotProtocol.IShard { + const graph = shard.graph; + const language = graph.language as GraphLanguage; + const context: IContext = { + root, + raw, + shard, + graph, + language, + target: `${graph.targetTriple}#${graph.commandDigest}`, + nodes: new Map(), + ids: new Map(), + files: new Map(), + edges: new Map(), + unresolved: [], + }; + for (const source of graph.sources) fileNode(context, source.uri); + for (const symbol of graph.symbols) symbolNode(context, symbol); + for (const macro of graph.macros) macroNode(context, macro); + for (const module of graph.modules) { + moduleNode(context, module.name, module.evidence); + } + + for (const symbol of graph.symbols) { + const id = endpoint(context, symbol.id); + const owner = + symbol.ownerUsr === "" ? undefined : endpoint(context, symbol.ownerUsr); + const location = preferredRange(symbol); + addEdge( + context, + owner ?? fileNode(context, location.file), + id, + "contains", + location, + ); + if (symbol.exported) { + addEdge( + context, + fileNode(context, location.file), + id, + "exports", + location, + ); + } + } + for (const include of graph.includes) { + addEdge( + context, + fileNode(context, include.source), + fileNode(context, include.target), + "imports", + include.evidence, + ); + } + for (const module of graph.modules) { + addEdge( + context, + fileNode(context, module.evidence.file || graph.mainFileUri), + moduleNode(context, module.name, module.evidence), + "imports", + module.evidence, + ); + } + for (const occurrence of graph.occurrences) { + adaptOccurrence(context, occurrence); + } + // Occurrences carry the exact use-site span. Add them before the coarser + // semantic relation lane so endpoint-pair deduplication retains that span. + for (const relation of graph.relations) adaptRelation(context, relation); + for (const macro of graph.macros) adaptMacro(context, macro); + + const coverageByFamily = new Map( + shard.coverage.map((row) => [row.family, row.state]), + ); + const advertised = new Set(CPP_CLANG_FACTS); + const coverage: ISamchonGraphCoverage[] = snapshotLanguages.flatMap( + (coverageLanguage) => + GRAPH_EDGE_KINDS.map((family) => ({ + provider: CPP_CLANG_PROVIDER, + language: coverageLanguage, + target: context.target, + family, + state: + coverageLanguage === language && advertised.has(family) + ? (coverageByFamily.get( + family, + )! as ISamchonGraphCoverage["state"]) + : "unsupported", + })), + ); + const fallbackEvidence = evidenceOf(root, { + file: graph.mainFileUri, + startLine: 0, + startColumn: 0, + endLine: 0, + endColumn: 0, + }); + for (const row of coverage) { + if ( + row.language !== language || + row.state !== "partial" || + context.unresolved.some((site) => site.family === row.family) + ) { + continue; + } + context.unresolved.push({ + provider: CPP_CLANG_PROVIDER, + language, + target: context.target, + universe: raw.universe.digest, + family: row.family, + evidence: fallbackEvidence, + reason: "provider-gap", + }); + } + const diagnostics: ISamchonGraphDiagnostic[] = graph.diagnostics.map( + (row) => ({ + file: graphFile(root, row.range.file), + line: row.range.file === "" ? 0 : row.range.startLine + 1, + column: row.range.file === "" ? 0 : row.range.startColumn + 1, + code: row.code, + message: row.message, + severity: row.severity as ISamchonGraphDiagnostic["severity"], + }), + ); + return { + key: graphKey(shard.key), + target: context.target, + languages: [language], + nodes: [...context.nodes.values()].sort((left, right) => + compareText(left.id, right.id), + ), + edges: [...context.edges.values()].sort(compareEdge), + diagnostics, + coverage, + unresolved: context.unresolved, + sources: graph.sources.map((source) => ({ + file: sourceFile(root, source.uri), + checkerDigest: source.digest, + diskDigest: source.diskDigest, + })), + }; +} + +function symbolNode( + context: IContext, + symbol: ICppGraphSnapshot.ISymbol, +): string { + const range = preferredRange(symbol); + const file = graphFile( + context.root, + range.file || context.graph.mainFileUri, + ); + const kind = nodeKind(symbol.kind); + const display = symbol.qualifiedName || symbol.name; + const id = semanticGraphNodeId( + { + version: 2, + language: context.language, + symbol: symbol.id, + role: kind, + native: { key: symbol.id, stability: "semantic" }, + scope: { + target: context.shard.configuration, + translationUnit: graphFile( + context.root, + context.graph.mainFileUri, + ), + document: file, + }, + stability: "persistent", + }, + display, + ); + const node: ISamchonGraphNode = { + id, + kind, + language: context.language, + name: symbol.name, + ...(symbol.qualifiedName !== "" && symbol.qualifiedName !== symbol.name + ? { qualifiedName: symbol.qualifiedName } + : {}), + file, + external: isExternal(file), + exported: symbol.exported, + ...(symbol.signature === "" ? {} : { signature: symbol.signature }), + ...(validRange(range) + ? { evidence: evidenceOf(context.root, range) } + : {}), + ...(symbol.attributes.length === 0 + ? {} + : { + decorators: symbol.attributes.map((attribute) => ({ + name: attribute.name, + arguments: [], + })), + }), + }; + context.ids.set(symbol.id, id); + context.nodes.set(id, node); + return id; +} + +function macroNode(context: IContext, macro: ICppGraphSnapshot.IMacro): string { + const found = context.ids.get(macro.id); + if (found !== undefined) return found; + const file = graphFile( + context.root, + macro.definition.file || + macro.spelling.file || + macro.expansion.file || + context.graph.mainFileUri, + ); + const id = semanticGraphNodeId( + { + version: 2, + language: context.language, + symbol: macro.id, + role: "variable", + native: { key: macro.id, stability: "semantic" }, + scope: { + target: context.shard.configuration, + translationUnit: graphFile( + context.root, + context.graph.mainFileUri, + ), + document: file, + }, + stability: "persistent", + }, + macro.name, + ); + context.ids.set(macro.id, id); + context.nodes.set(id, { + id, + kind: "variable", + language: context.language, + name: macro.name, + file, + external: isExternal(file), + ...(validRange(macro.definition) + ? { evidence: evidenceOf(context.root, macro.definition) } + : {}), + }); + return id; +} + +function moduleNode( + context: IContext, + name: string, + range: ICppGraphSnapshot.IRange, +): string { + const raw = `module:${name}`; + const found = context.ids.get(raw); + if (found !== undefined) return found; + const file = graphFile( + context.root, + range.file || context.graph.mainFileUri, + ); + const id = semanticGraphNodeId( + { + version: 2, + language: context.language, + symbol: raw, + role: "module", + native: { key: raw, stability: "semantic" }, + scope: { + target: context.shard.configuration, + translationUnit: graphFile( + context.root, + context.graph.mainFileUri, + ), + }, + stability: "persistent", + }, + name, + ); + context.ids.set(raw, id); + context.nodes.set(id, { + id, + kind: "module", + language: context.language, + name, + file, + external: isExternal(file), + }); + return id; +} + +function fileNode(context: IContext, uri: string): string { + const key = uri || context.graph.mainFileUri; + const found = context.files.get(key); + if (found !== undefined) return found; + const file = graphFile(context.root, key); + const id = semanticGraphNodeId( + { + version: 2, + language: context.language, + symbol: `file:${key}`, + role: "file", + native: { key, stability: "semantic" }, + scope: { + target: context.shard.configuration, + translationUnit: graphFile( + context.root, + context.graph.mainFileUri, + ), + document: file, + }, + stability: "persistent", + }, + file, + ); + context.files.set(key, id); + context.nodes.set(id, { + id, + kind: "file", + language: context.language, + name: path.posix.basename(file), + qualifiedName: file, + file, + external: isExternal(file), + }); + return id; +} + +function endpoint(context: IContext, raw: string): string { + const found = context.ids.get(raw); + if (found !== undefined) return found; + const name = raw; + const id = semanticGraphNodeId( + { + version: 2, + language: context.language, + symbol: name, + role: "external_symbol", + native: { key: name, stability: "semantic" }, + scope: { + target: context.shard.configuration, + translationUnit: graphFile( + context.root, + context.graph.mainFileUri, + ), + }, + stability: "persistent", + }, + name, + ); + context.ids.set(raw, id); + context.nodes.set(id, { + id, + kind: "external_symbol", + language: context.language, + name, + file: "bundled:///clang/external", + external: true, + }); + return id; +} + +function adaptRelation( + context: IContext, + relation: ICppGraphSnapshot.IRelation, +): void { + const subject = endpoint(context, relation.subjectId); + const object = endpoint(context, relation.objectId); + if (relation.roles & (ROLE.childOf | ROLE.containedBy)) { + addEdge(context, object, subject, "contains", relation.evidence); + } + if (relation.roles & ROLE.baseOf) { + addEdge(context, object, subject, "extends", relation.evidence); + } + if (relation.roles & ROLE.extendedBy) { + addEdge(context, object, subject, "extends", relation.evidence); + } + if (relation.roles & ROLE.overrideOf) { + addEdge(context, subject, object, "overrides", relation.evidence); + } + if (relation.roles & ROLE.calledBy) { + addEdge(context, object, subject, "calls", relation.evidence); + } + if (relation.roles & ROLE.accessorOf) { + addEdge(context, subject, object, "accesses", relation.evidence); + } + if (relation.roles & ROLE.specializationOf) { + addEdge(context, subject, object, "instantiates", relation.evidence); + } +} + +function adaptOccurrence( + context: IContext, + occurrence: ICppGraphSnapshot.IOccurrence, +): void { + const target = endpoint(context, occurrence.id); + const range = validRange(occurrence.expansion) + ? occurrence.expansion + : occurrence.spelling; + const owner = + occurrence.containerId === "" + ? fileNode(context, range.file) + : endpoint(context, occurrence.containerId); + if (occurrence.roles & ROLE.call) { + addEdge(context, owner, target, "calls", range); + } + if ((occurrence.roles & ROLE.call) && occurrence.targetKind === 23) { + addEdge(context, owner, target, "instantiates", range); + } + if (occurrence.roles & (ROLE.read | ROLE.write)) { + addEdge(context, owner, target, "accesses", range); + } + if (occurrence.roles & ROLE.reference) { + addEdge(context, owner, target, "references", range); + } + if ( + (occurrence.roles & (ROLE.reference | ROLE.nameReference)) && + TYPE_KINDS.has(occurrence.targetKind) + ) { + addEdge(context, owner, target, "type_ref", range); + } + if ((occurrence.roles & ROLE.dynamic) && validRange(range)) { + context.unresolved.push({ + provider: CPP_CLANG_PROVIDER, + language: context.language, + target: context.target, + universe: context.raw.universe.digest, + family: "dispatches", + evidence: evidenceOf(context.root, range), + reason: "dynamic", + candidates: [target], + }); + } +} + +function adaptMacro(context: IContext, macro: ICppGraphSnapshot.IMacro): void { + const target = macroNode(context, macro); + const range = validRange(macro.expansion) + ? macro.expansion + : macro.spelling; + const definitionFile = + macro.definition.file || context.graph.mainFileUri; + if (macro.roles & ROLE.reference) { + addEdge( + context, + fileNode(context, range.file || definitionFile), + target, + "references", + range, + ); + } + if (macro.roles & (ROLE.declaration | ROLE.definition)) { + addEdge( + context, + fileNode(context, definitionFile), + target, + "contains", + macro.definition, + ); + } +} + +function addEdge( + context: IContext, + from: string, + to: string, + kind: GraphEdgeKind, + range: ICppGraphSnapshot.IRange, +): void { + if (from === to) return; + const key = `${from}\0${to}\0${kind}`; + if (context.edges.has(key)) return; + context.edges.set(key, { + from, + to, + kind, + ...(validRange(range) + ? { evidence: evidenceOf(context.root, range) } + : {}), + }); +} + +function preferredRange( + symbol: ICppGraphSnapshot.ISymbol, +): ICppGraphSnapshot.IRange { + return validRange(symbol.definition) ? symbol.definition : symbol.declaration; +} + +function evidenceOf( + root: string, + range: ICppGraphSnapshot.IRange, +): ISamchonGraphEvidence { + return { + file: graphFile(root, range.file), + startLine: range.startLine + 1, + startCol: range.startColumn + 1, + endLine: range.endLine + 1, + endCol: range.endColumn + 1, + }; +} + +function graphFile(root: string, source: string): string { + if (source === "") return ""; + assertSupportedSource(source); + let absolute = source; + if (source.startsWith("file:")) { + absolute = fileURLToPath(source); + } + if (!path.isAbsolute(absolute)) return absolute.replaceAll("\\", "/"); + const relative = path.relative(root, absolute).replaceAll("\\", "/"); + /* c8 ignore next -- only Windows cross-volume or UNC sources reach this guard. */ + if (path.isAbsolute(relative)) return externalGraphFile(absolute); + return relative; +} + +/* c8 ignore start -- only Windows cross-volume or UNC sources reach this helper. */ +function externalGraphFile(source: string): string { + const normalized = path.normalize(source); + const identity = normalized.toLowerCase(); + const basename = encodeURIComponent(path.basename(normalized) || "source"); + return `bundled:///clang/filesystem/${sha256(identity)}/${basename}`; +} +/* c8 ignore stop */ + +function sourceFile(root: string, source: string): string { + assertSupportedSource(source); + if (source.startsWith("bundled:///")) return source; + if (source.startsWith("file:")) { + return path.normalize(fileURLToPath(source)); + } + return path.normalize( + path.isAbsolute(source) ? source : path.resolve(root, source), + ); +} + +function assertSupportedSource(source: string): void { + /* c8 ignore next 3 -- assertGraph validates every native source before adaptation. */ + if (!isSupportedSource(source)) { + throw new Error(`unsupported C/C++ graph source URI: ${source}`); + } +} + +function isSupportedSource(source: string): boolean { + if (source.startsWith("bundled:///")) { + const relative = source.slice("bundled:///".length); + return ( + relative !== "" && + !relative.includes("\\") && + path.posix.normalize(relative) === relative && + relative + .split("/") + .every((part) => part !== "" && part !== "." && part !== "..") + ); + } + if (source.startsWith("file:")) { + try { + return path.isAbsolute(fileURLToPath(source)); + } catch { + return false; + } + } + return ( + path.isAbsolute(source) || + !/^[A-Za-z][A-Za-z0-9+.-]*:/u.test(source) + ); +} + +function graphKey(raw: string): string { + return `cpp-shard:${sha256(raw)}`; +} + +function isExternal(file: string): boolean { + return file.startsWith("../") || file.startsWith("bundled:///"); +} + +function validRange(range: ICppGraphSnapshot.IRange): boolean { + return range.file !== ""; +} + +function nodeKind(kind: number): GraphNodeKind { + return NODE_KINDS[kind] ?? "external_symbol"; +} + +const NODE_KINDS: Record = { + 1: "module", + 2: "namespace", + 3: "namespace", + 4: "variable", + 5: "file", + 6: "enum", + 7: "class", + 8: "class", + 9: "interface", + 10: "type", + 11: "type", + 12: "type", + 13: "function", + 14: "variable", + 15: "field", + 16: "field", + 17: "method", + 18: "method", + 19: "method", + 20: "property", + 21: "property", + 22: "property", + 23: "constructor", + 24: "method", + 25: "method", + 26: "parameter", + 27: "type", + 28: "type", + 29: "type", + 30: "parameter", + 31: "interface", +}; + +function helloOf( + raw: ICppGraphSnapshot, + shards: ReadonlyMap, + selectedLanguages: ReadonlySet, +): GraphSnapshotProtocol.IHello { + const languages = new Set(); + for (const shard of shards.values()) { + if ( + (shard.graph.language === "c" || shard.graph.language === "cpp") && + selectedLanguages.has(shard.graph.language) + ) { + languages.add(shard.graph.language); + } + } + return { + type: "hello", + protocolVersion: 1, + schemaVersion: 1, + producerSchemaVersion: raw.schemaVersion, + provider: CPP_CLANG_PROVIDER, + producer: raw.producer.name, + producerVersion: `${raw.producer.version} (${raw.producer.commit})`, + compilerVersion: raw.producer.version, + languages: [...languages].sort(compareText), + authority: "compiler", + supportedFacts: [...CPP_CLANG_FACTS], + capabilities: [...CAPABILITIES], + }; +} + +function factsOf( + hello: GraphSnapshotProtocol.IHello, + begin: GraphSnapshotProtocol.IBegin, + shards: readonly GraphSnapshotProtocol.IShard[], +): Pick< + IBulkGraphSession.ISnapshot, + | "languages" + | "nodes" + | "edges" + | "diagnostics" + | "coverage" + | "unresolved" + | "provenance" +> { + return { + languages: [...hello.languages], + nodes: shards.flatMap((shard) => shard.nodes), + edges: shards.flatMap((shard) => shard.edges), + diagnostics: shards.flatMap((shard) => shard.diagnostics), + coverage: shards.flatMap((shard) => shard.coverage), + unresolved: shards.flatMap((shard) => shard.unresolved), + provenance: { + provider: hello.provider, + authority: hello.authority, + facts: [...hello.supportedFacts], + schemaVersion: hello.producerSchemaVersion, + tool: hello.producer, + toolVersion: hello.producerVersion, + compilerVersion: hello.compilerVersion, + protocolVersion: hello.protocolVersion, + universe: begin.universe, + capabilities: [...hello.capabilities], + }, + }; +} + +function assertSnapshot(raw: ICppGraphSnapshot, commit: string): void { + if ( + raw === null || + typeof raw !== "object" || + raw.protocolVersion !== 1 || + raw.schemaVersion !== 1 + ) { + throw new Error("C/C++ clang graph: unsupported producer protocol/schema"); + } + if ( + raw.producer?.name !== "samchon-clangd" || + typeof raw.producer.version !== "string" || + typeof raw.producer.commit !== "string" || + !raw.producer.commit.includes(commit) || + raw.producer.version === "" + ) { + throw new Error("C/C++ clang graph: producer identity/commit mismatch"); + } + if ( + !SHA256.test(raw.universe?.digest) || + !SHA256.test(raw.generation) || + !Number.isSafeInteger(raw.sequence) || + raw.sequence < 1 || + !Array.isArray(raw.upserts) || + !Array.isArray(raw.deletes) || + !Array.isArray(raw.manifest) || + !isRecord(raw.page) || + !isRecord(raw.phases) + ) { + throw new Error("C/C++ clang graph: malformed generation envelope"); + } + if (raw.baseGeneration !== null && !SHA256.test(raw.baseGeneration)) { + throw new Error("C/C++ clang graph: malformed base generation"); + } + if ( + !isRecord(raw.universe) || + !canonicalStrings(raw.universe.targets, false) || + raw.universe.targets.length === 0 || + !canonicalStrings(raw.universe.workspaceRoots, true) || + !canonicalStrings(raw.universe.toolchains, false) || + !canonicalStrings(raw.universe.configurations, false) + ) { + throw new Error("C/C++ clang graph: malformed universe"); + } + if (!canonicalStrings(raw.deletes, false)) { + throw new Error("C/C++ clang graph: malformed delete set"); + } + if ( + !nonnegativeInteger(raw.page.offset) || + !nonnegativeInteger(raw.page.count) || + !nonnegativeInteger(raw.page.total) || + raw.page.offset !== 0 || + raw.page.count !== raw.upserts.length || + raw.page.total !== raw.upserts.length || + raw.page.nextCursor !== null + ) { + throw new Error("C/C++ clang graph: malformed assembled page"); + } + const manifestKeys = new Set(); + for (const entry of raw.manifest) { + if ( + !isRecord(entry) || + typeof entry.key !== "string" || + entry.key === "" || + manifestKeys.has(entry.key) || + typeof entry.digest !== "string" || + !SHA256.test(entry.digest) + ) { + throw new Error("C/C++ clang graph: malformed native manifest"); + } + manifestKeys.add(entry.key); + } + if ( + !nonnegativeInteger(raw.phases.validationMillis) || + !nonnegativeInteger(raw.phases.semanticMillis) || + !nonnegativeInteger(raw.phases.shardMillis) || + !nonnegativeInteger(raw.phases.encodeMillis) || + !nonnegativeInteger(raw.phases.totalMillis) || + typeof raw.phases.cacheHit !== "boolean" || + raw.phases.totalMillis !== + raw.phases.validationMillis + + raw.phases.semanticMillis + + raw.phases.shardMillis + + raw.phases.encodeMillis + ) { + throw new Error("C/C++ clang graph: malformed phase telemetry"); + } +} + +function assertShard( + shard: ICppGraphSnapshot.IShard, + expectedProducerFingerprint: string, +): void { + if ( + !isRecord(shard) || + typeof shard.key !== "string" || + shard.key === "" || + typeof shard.source !== "string" || + shard.source === "" || + typeof shard.configuration !== "string" || + !SHA256.test(shard.digest) || + !SHA256.test(shard.checkerDigest) || + !SHA256.test(shard.interfaceFingerprint) || + !Array.isArray(shard.coverage) || + !isRecord(shard.graph) || + shard.configuration !== shard.graph.commandDigest || + shard.graph.hadErrors + ) { + throw new Error(`C/C++ clang graph: malformed shard ${shard.key}`); + } + assertGraph(shard.graph, shard.key); + if (shard.graph.producerFingerprint !== expectedProducerFingerprint) { + throw new Error( + `C/C++ clang graph: compiler fingerprint mismatch ${shard.key}`, + ); + } + const source = shard.graph.sources.find( + (entry) => entry.uri === shard.graph.mainFileUri, + ); + if ( + source?.digest !== shard.checkerDigest || + shard.source !== shard.graph.mainFile + ) { + throw new Error(`C/C++ clang graph: mismatched main source ${shard.key}`); + } + const expected = sha256( + `${shard.key}\n${shard.checkerDigest}\n${shard.interfaceFingerprint}\n${JSON.stringify(shard.graph)}`, + ); + if (expected !== shard.digest) { + throw new Error(`C/C++ clang graph: shard digest mismatch ${shard.key}`); + } + const families = new Set(); + for (const row of shard.coverage) { + if ( + families.has(row.family) || + !GRAPH_EDGE_KINDS.includes(row.family as GraphEdgeKind) || + !["complete", "partial", "unsupported"].includes(row.state) + ) { + throw new Error(`C/C++ clang graph: invalid coverage ${shard.key}`); + } + families.add(row.family); + } + if (families.size !== GRAPH_EDGE_KINDS.length) { + throw new Error(`C/C++ clang graph: incomplete coverage ${shard.key}`); + } +} + +function assertGraph(graph: ICppGraphSnapshot.ITU, key: string): void { + if ( + typeof graph.producerFingerprint !== "string" || + !SHA256.test(graph.producerFingerprint) || + typeof graph.mainFileUri !== "string" || + graph.mainFileUri === "" || + !isSupportedSource(graph.mainFileUri) || + typeof graph.mainFile !== "string" || + graph.mainFile === "" || + !isSupportedSource(graph.mainFile) || + typeof graph.directory !== "string" || + !canonicalStrings(graph.commandLine, true, false) || + typeof graph.output !== "string" || + typeof graph.commandDigest !== "string" || + !SHA256.test(graph.commandDigest) || + typeof graph.toolchainFingerprint !== "string" || + !SHA256.test(graph.toolchainFingerprint) || + typeof graph.targetTriple !== "string" || + graph.targetTriple === "" || + (graph.language !== "c" && graph.language !== "cpp") || + typeof graph.hadErrors !== "boolean" || + !Array.isArray(graph.sources) || + !Array.isArray(graph.symbols) || + !Array.isArray(graph.occurrences) || + !Array.isArray(graph.relations) || + !Array.isArray(graph.macros) || + !Array.isArray(graph.includes) || + !Array.isArray(graph.missingIncludes) || + graph.missingIncludes.length !== 0 || + !Array.isArray(graph.modules) || + !Array.isArray(graph.diagnostics) + ) { + throw new Error(`C/C++ clang graph: malformed graph ${key}`); + } + const sources = new Set(); + for (const source of graph.sources) { + if ( + !isRecord(source) || + typeof source.uri !== "string" || + source.uri === "" || + !isSupportedSource(source.uri) || + sources.has(source.uri) || + typeof source.digest !== "string" || + !SHA256.test(source.digest) || + typeof source.diskDigest !== "string" || + (source.diskDigest !== "" && !SHA256.test(source.diskDigest)) || + !nonnegativeInteger(source.flags) + ) { + throw new Error(`C/C++ clang graph: malformed source ${key}`); + } + sources.add(source.uri); + } + const symbols = new Set(); + for (const symbol of graph.symbols) { + if ( + !isRecord(symbol) || + typeof symbol.usr !== "string" || + symbol.usr === "" || + typeof symbol.id !== "string" || + symbol.id === "" || + symbols.has(symbol.id) || + typeof symbol.name !== "string" || + symbol.name === "" || + typeof symbol.qualifiedName !== "string" || + typeof symbol.ownerUsr !== "string" || + typeof symbol.signature !== "string" || + !nonnegativeInteger(symbol.kind) || + !nonnegativeInteger(symbol.subKind) || + !nonnegativeInteger(symbol.properties) || + typeof symbol.local !== "boolean" || + typeof symbol.internal !== "boolean" || + typeof symbol.anonymous !== "boolean" || + typeof symbol.exported !== "boolean" || + !validNativeRange(symbol.declaration) || + !validNativeRange(symbol.definition) || + !Array.isArray(symbol.attributes) || + symbol.attributes.some( + (attribute) => + !isRecord(attribute) || + typeof attribute.name !== "string" || + attribute.name === "" || + !validNativeRange(attribute.range), + ) + ) { + throw new Error(`C/C++ clang graph: malformed symbol ${key}`); + } + symbols.add(symbol.id); + } + for (const occurrence of graph.occurrences) { + if ( + !isRecord(occurrence) || + typeof occurrence.usr !== "string" || + occurrence.usr === "" || + typeof occurrence.id !== "string" || + occurrence.id === "" || + typeof occurrence.containerId !== "string" || + !nonnegativeInteger(occurrence.roles) || + !nonnegativeInteger(occurrence.targetKind) || + !validNativeRange(occurrence.spelling) || + !validNativeRange(occurrence.expansion) + ) { + throw new Error(`C/C++ clang graph: malformed occurrence ${key}`); + } + } + for (const relation of graph.relations) { + if ( + !isRecord(relation) || + typeof relation.subjectId !== "string" || + relation.subjectId === "" || + typeof relation.objectId !== "string" || + relation.objectId === "" || + !nonnegativeInteger(relation.roles) || + !validNativeRange(relation.evidence) + ) { + throw new Error(`C/C++ clang graph: malformed relation ${key}`); + } + } + // Macro rows are occurrences. Definition and reference rows intentionally + // share the stable macro endpoint ID. + for (const macro of graph.macros) { + if ( + !isRecord(macro) || + typeof macro.usr !== "string" || + macro.usr === "" || + typeof macro.id !== "string" || + macro.id === "" || + typeof macro.name !== "string" || + macro.name === "" || + !nonnegativeInteger(macro.roles) || + !validNativeRange(macro.definition) || + !validNativeRange(macro.spelling) || + !validNativeRange(macro.expansion) + ) { + throw new Error(`C/C++ clang graph: malformed macro ${key}`); + } + } + for (const include of graph.includes) { + if ( + !isRecord(include) || + typeof include.source !== "string" || + include.source === "" || + !isSupportedSource(include.source) || + typeof include.target !== "string" || + include.target === "" || + !isSupportedSource(include.target) || + typeof include.spelling !== "string" || + typeof include.angled !== "boolean" || + typeof include.moduleImported !== "boolean" || + !validNativeRange(include.evidence) + ) { + throw new Error(`C/C++ clang graph: malformed include ${key}`); + } + } + for (const module of graph.modules) { + if ( + !isRecord(module) || + typeof module.name !== "string" || + module.name === "" || + !nonnegativeInteger(module.roles) || + !validNativeRange(module.evidence) + ) { + throw new Error(`C/C++ clang graph: malformed module ${key}`); + } + } + for (const diagnostic of graph.diagnostics) { + if ( + !isRecord(diagnostic) || + typeof diagnostic.message !== "string" || + diagnostic.message === "" || + typeof diagnostic.code !== "string" || + diagnostic.code === "" || + !["error", "warning", "info", "hint"].includes(diagnostic.severity) || + !validNativeRange(diagnostic.range) + ) { + throw new Error(`C/C++ clang graph: malformed diagnostic ${key}`); + } + } +} + +function nativeManifest( + shards: ReadonlyMap, +): Array<{ key: string; digest: string }> { + return [...shards.values()] + .sort((left, right) => { + const mainFile = Buffer.compare( + Buffer.from(left.graph.mainFile, "utf8"), + Buffer.from(right.graph.mainFile, "utf8"), + ); + if (mainFile !== 0) return mainFile; + return Buffer.compare( + Buffer.from(left.configuration, "utf8"), + Buffer.from(right.configuration, "utf8"), + ); + }) + .map((shard) => ({ key: shard.key, digest: shard.digest })); +} + +function assertNativeGeneration( + raw: ICppGraphSnapshot, + manifest: Array<{ key: string; digest: string }>, + shards: ReadonlyMap, +): void { + const configurations = [ + ...new Set([...shards.values()].map((shard) => shard.configuration)), + ].sort(compareText); + const targets = [ + ...new Set([...shards.values()].map((shard) => shard.graph.targetTriple)), + ].sort(compareText); + const toolchains = [ + ...new Set( + [...shards.values()].map( + (shard) => shard.graph.toolchainFingerprint, + ), + ), + ].sort(compareText); + if ( + JSON.stringify(configurations) !== + JSON.stringify(raw.universe.configurations) || + JSON.stringify(targets) !== JSON.stringify(raw.universe.targets) || + JSON.stringify(toolchains) !== JSON.stringify(raw.universe.toolchains) + ) { + throw new Error("C/C++ clang graph: universe does not describe its shards"); + } + const generationMaterial = manifest + .map( + (entry) => + `${Buffer.byteLength(entry.key, "utf8")}:${entry.key}${entry.digest}`, + ) + .join(""); + let universeMaterial = coordinate( + "producer", + producerFingerprint(raw.producer), + ); + for (const target of raw.universe.targets) + universeMaterial += coordinate("target", target); + for (const root of raw.universe.workspaceRoots) + universeMaterial += coordinate("root", root); + for (const toolchain of raw.universe.toolchains) + universeMaterial += coordinate("toolchain", toolchain); + for (const configuration of raw.universe.configurations) + universeMaterial += coordinate("configuration", configuration); + const universe = sha256(universeMaterial); + if ( + universe !== raw.universe.digest || + sha256(universe + generationMaterial) !== raw.generation + ) { + throw new Error("C/C++ clang graph: generation digest mismatch"); + } +} + +function coordinate(label: string, value: string): string { + return `${label}:${Buffer.byteLength(value, "utf8")}:${value}`; +} + +function producerFingerprint( + producer: ICppGraphSnapshot.IProducer, +): string { + return sha256( + `samchon-graph-schema:1\nversion:${producer.version}\nrepository:${producer.commit}`, + ); +} + +function validNativeRange( + value: unknown, +): value is ICppGraphSnapshot.IRange { + if ( + !isRecord(value) || + typeof value.file !== "string" || + !nonnegativeInteger(value.startLine) || + !nonnegativeInteger(value.startColumn) || + !nonnegativeInteger(value.endLine) || + !nonnegativeInteger(value.endColumn) + ) { + return false; + } + if (value.file === "") { + return ( + value.startLine === 0 && + value.startColumn === 0 && + value.endLine === 0 && + value.endColumn === 0 + ); + } + return ( + isSupportedSource(value.file) && + (value.endLine > value.startLine || + (value.endLine === value.startLine && + value.endColumn >= value.startColumn)) + ); +} + +function canonicalStrings( + value: unknown, + allowEmpty: boolean, + canonical = true, +): value is string[] { + if (!Array.isArray(value)) return false; + let prior: string | undefined; + for (const entry of value) { + if (typeof entry !== "string" || (!allowEmpty && entry === "")) { + return false; + } + if (canonical && prior !== undefined && entry <= prior) return false; + prior = entry; + } + return true; +} + +function nonnegativeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function compareEdge( + left: ISamchonGraphEdge, + right: ISamchonGraphEdge, +): number { + return compareText( + `${left.from}\0${left.to}\0${left.kind}`, + `${right.from}\0${right.to}\0${right.kind}`, + ); +} diff --git a/packages/graph/src/provider/cpp/ICppGraphSnapshot.ts b/packages/graph/src/provider/cpp/ICppGraphSnapshot.ts new file mode 100644 index 00000000..27c85386 --- /dev/null +++ b/packages/graph/src/provider/cpp/ICppGraphSnapshot.ts @@ -0,0 +1,180 @@ +export interface ICppGraphSnapshot { + protocolVersion: number; + schemaVersion: number; + producer: ICppGraphSnapshot.IProducer; + universe: ICppGraphSnapshot.IUniverse; + sequence: number; + generation: string; + baseGeneration: string | null; + upserts: ICppGraphSnapshot.IShard[]; + deletes: string[]; + manifest: ICppGraphSnapshot.IManifestEntry[]; + page: ICppGraphSnapshot.IPage; + phases: ICppGraphSnapshot.IPhases; +} + +export namespace ICppGraphSnapshot { + export interface IProducer { + name: string; + version: string; + commit: string; + } + + export interface IUniverse { + digest: string; + targets: string[]; + workspaceRoots: string[]; + toolchains: string[]; + configurations: string[]; + } + + export interface IManifestEntry { + key: string; + digest: string; + } + + export interface IPage { + offset: number; + count: number; + total: number; + nextCursor: string | null; + } + + export interface IPhases { + validationMillis: number; + semanticMillis: number; + shardMillis: number; + encodeMillis: number; + totalMillis: number; + cacheHit: boolean; + } + + export interface IShard { + key: string; + source: string; + configuration: string; + checkerDigest: string; + interfaceFingerprint: string; + digest: string; + graph: ITU; + coverage: Array<{ + family: string; + state: string; + }>; + } + + export interface ITU { + producerFingerprint: string; + mainFileUri: string; + mainFile: string; + directory: string; + commandLine: string[]; + output: string; + commandDigest: string; + toolchainFingerprint: string; + targetTriple: string; + language: string; + hadErrors: boolean; + sources: ISource[]; + symbols: ISymbol[]; + occurrences: IOccurrence[]; + relations: IRelation[]; + macros: IMacro[]; + includes: IInclude[]; + missingIncludes: IMissingInclude[]; + modules: IModule[]; + diagnostics: IDiagnostic[]; + } + + export interface IRange { + file: string; + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; + } + + export interface ISource { + uri: string; + digest: string; + diskDigest: string; + flags: number; + } + + export interface ISymbol { + usr: string; + id: string; + name: string; + qualifiedName: string; + ownerUsr: string; + signature: string; + kind: number; + subKind: number; + properties: number; + local: boolean; + internal: boolean; + anonymous: boolean; + exported: boolean; + declaration: IRange; + definition: IRange; + attributes: Array<{ + name: string; + range: IRange; + }>; + } + + export interface IOccurrence { + usr: string; + id: string; + containerId: string; + roles: number; + targetKind: number; + spelling: IRange; + expansion: IRange; + } + + export interface IRelation { + subjectId: string; + objectId: string; + roles: number; + evidence: IRange; + } + + export interface IMacro { + usr: string; + id: string; + name: string; + roles: number; + definition: IRange; + spelling: IRange; + expansion: IRange; + } + + export interface IInclude { + source: string; + target: string; + spelling: string; + angled: boolean; + moduleImported: boolean; + evidence: IRange; + } + + export interface IMissingInclude { + source: string; + spelling: string; + angled: boolean; + } + + export interface IModule { + name: string; + roles: number; + evidence: IRange; + } + + export interface IDiagnostic { + message: string; + code: string; + severity: string; + range: IRange; + } +} diff --git a/packages/graph/src/provider/cpp/cppGraphProvider.ts b/packages/graph/src/provider/cpp/cppGraphProvider.ts new file mode 100644 index 00000000..4653e9e2 --- /dev/null +++ b/packages/graph/src/provider/cpp/cppGraphProvider.ts @@ -0,0 +1,174 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { spawnableCommand } from "../../utils/spawnableCommand"; +import { assertGraphSnapshotContract } from "../assertGraphSnapshotContract"; +import { IGraphProvider } from "../IGraphProvider"; +import { resolveProviderCommand } from "../resolveProviderCommand"; +import { standardScipProviders } from "../scip/standardScipProviders"; +import { CPP_CLANG_FACTS } from "./CPP_CLANG_FACTS"; +import { CPP_CLANG_PRODUCER_COMMIT } from "./CPP_CLANG_PRODUCER_COMMIT"; +import { CPP_CLANG_PROVIDER } from "./CPP_CLANG_PROVIDER"; +import { CppGraphClient } from "./CppGraphClient"; + +const OVERRIDE = "SAMCHON_GRAPH_CLANGD_SNAPSHOT"; +const clangScipProvider = standardScipProviders.find( + (provider) => provider.name === "scip-clang", +); +/* c8 ignore next 4 -- the static standard-provider registry always contains + * the scip-clang descriptor; startup must still fail closed if it is edited. */ +if (clangScipProvider === undefined) { + throw new Error("clangd-snapshot: the scip-clang fallback is not registered"); +} + +export const cppGraphProvider: IGraphProvider = { + name: CPP_CLANG_PROVIDER, + languages: ["c", "cpp"], + authority: "compiler", + facts: CPP_CLANG_FACTS, + resolution: { + commands: ["samchon-clangd", "clangd"], + projectCommandSources: [ + "compile_commands.json", + "build/compile_commands.json", + ], + environmentOverrides: [OVERRIDE], + }, + fallbacks: [clangScipProvider], + buildInputs: clangScipProvider.buildInputs, + configuration: (_root, env) => [ + `producer-commit=${CPP_CLANG_PRODUCER_COMMIT}`, + `${OVERRIDE}=${env[OVERRIDE] ?? "unconfigured"}`, + ], + refuse: (options) => { + const refused = [ + options.server === undefined ? undefined : "server", + options.maxFiles === undefined ? undefined : "maxFiles", + options.lspReferenceLimit === undefined + ? undefined + : "lspReferenceLimit", + ].filter((value): value is string => value !== undefined); + return refused.length === 0 + ? undefined + : `c, cpp: ${CPP_CLANG_PROVIDER} publishes whole-compilation-database generations and cannot honor ${refused.join(", ")}`; + }, + resolve: (root, env) => resolvePinned(root, env), + prepare: (root) => { + if (compilationDatabase(root) === undefined) { + throw new Error( + "clangd-snapshot: compile_commands.json or build/compile_commands.json must contain at least one command", + ); + } + }, + open: (props) => { + // Sized for the machine, like every other producer this repository + // launches. `--background-index` is what makes a whole-compilation-database + // snapshot possible at all, and clangd's `-j` bounds the workers it uses + // for it; left unset it takes the core count, and each worker holds a + // translation unit's AST while it runs. + // + // The bound is measured rather than assumed. A 16 GiB CI host indexing + // libuv and fmt at the default width ran out of memory — a trace of the + // host recorded free memory collapsing to 173 MiB and then 35 MiB, with + // the sawtooth of repeated kills before it — and took the runner agent + // with it. Eight GiB per worker is this repository's figure, chosen + // against that observation and not quoted from clangd: sixteen was not + // enough at four, so the rule has to land below two there rather than + // shave a worker off and call it sized. + // + // What this cannot do is bound what the producer retains for the whole + // database, which is a function of the project rather than of the worker + // count. That makes the narrow width a measurement as much as a fix: if + // one worker still exhausts the host, concurrency was never the term that + // mattered, and the answer lies in the producer rather than here. + const workers = Math.max( + 1, + Math.min( + os.availableParallelism(), + Math.floor(os.totalmem() / (8 * 1024 * 1024 * 1024)), + ), + ); + const command = spawnableCommand.append( + { ...props.command, args: [...props.command.args] }, + ["--background-index", `-j=${String(workers)}`], + ); + return new CppGraphClient({ + root: props.root, + languages: props.languages, + command: command.command, + args: command.args, + producerCommit: CPP_CLANG_PRODUCER_COMMIT, + initializationOptions: props.options.initializationOptions, + requestTimeoutMs: props.options.lspTimeoutMs, + readyTimeoutMs: props.options.lspReadyTimeoutMs, + maxMessageBytes: props.options.lspMaxMessageBytes, + windowsVerbatimArguments: command.windowsVerbatimArguments, + validate: (snapshot) => + assertGraphSnapshotContract( + snapshot, + cppGraphProvider, + props.languages, + props.root, + ), + }); + }, +}; + +function resolvePinned( + root: string, + env: NodeJS.ProcessEnv, +): IGraphProvider.ICommand | undefined { + for (const command of ["samchon-clangd", "clangd"]) { + const candidate = resolveProviderCommand(root, env, { + command, + override: OVERRIDE, + }); + if (candidate !== undefined && hasPinnedVersion(root, env, candidate)) { + return candidate; + } + } + return undefined; +} + +function hasPinnedVersion( + root: string, + env: NodeJS.ProcessEnv, + command: IGraphProvider.ICommand, +): boolean { + const invocation = spawnableCommand.append( + { ...command, args: [...command.args] }, + ["--version"], + ); + const result = spawnSync(invocation.command, invocation.args, { + cwd: root, + encoding: "utf8", + env, + shell: false, + timeout: 10_000, + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }); + return ( + result.status === 0 && + result.error === undefined && + result.stdout.includes(CPP_CLANG_PRODUCER_COMMIT) + ); +} + +function compilationDatabase(root: string): string | undefined { + for (const relative of [ + "compile_commands.json", + path.join("build", "compile_commands.json"), + ]) { + const candidate = path.join(root, relative); + try { + const parsed = JSON.parse(fs.readFileSync(candidate, "utf8")) as unknown; + if (Array.isArray(parsed) && parsed.length !== 0) return candidate; + } catch { + continue; + } + } + return undefined; +} diff --git a/packages/graph/src/provider/cpp/index.ts b/packages/graph/src/provider/cpp/index.ts new file mode 100644 index 00000000..37ec6e5b --- /dev/null +++ b/packages/graph/src/provider/cpp/index.ts @@ -0,0 +1,7 @@ +export * from "./CPP_CLANG_FACTS"; +export * from "./CPP_CLANG_PRODUCER_COMMIT"; +export * from "./CPP_CLANG_PROVIDER"; +export * from "./CppGraphClient"; +export * from "./CppGraphSnapshotAdapter"; +export * from "./ICppGraphSnapshot"; +export * from "./cppGraphProvider"; diff --git a/packages/graph/src/provider/fallbackCoverage.ts b/packages/graph/src/provider/fallbackCoverage.ts new file mode 100644 index 00000000..be392c91 --- /dev/null +++ b/packages/graph/src/provider/fallbackCoverage.ts @@ -0,0 +1,22 @@ +import { ISamchonGraphCoverage } from "../structures"; +import { GRAPH_EDGE_KINDS, GraphLanguage } from "../typings"; +import { coverageRows } from "./coverageRows"; + +/** + * Truthful coverage for a generic LSP or static lane. + * + * These lanes attempt multiple families heuristically and cannot make absence + * meaningful. Every family is therefore partial in one explicitly named + * fallback target. + */ +export function fallbackCoverage( + provider: "@samchon/graph-lsp" | "@samchon/graph-sitter", + languages: readonly GraphLanguage[], +): ISamchonGraphCoverage[] { + return coverageRows( + provider, + languages, + "fallback/default", + new Set(GRAPH_EDGE_KINDS), + ); +} diff --git a/packages/graph/src/provider/go/goGraphProvider.ts b/packages/graph/src/provider/go/goGraphProvider.ts index 353c325f..6e7df11a 100644 --- a/packages/graph/src/provider/go/goGraphProvider.ts +++ b/packages/graph/src/provider/go/goGraphProvider.ts @@ -10,6 +10,30 @@ import { resolveProviderCommand } from "../resolveProviderCommand"; import { toolchainVersion } from "../toolchainVersion"; import { sidecarProvider } from "../sidecar"; +const GO_GRAPH_TOOLS = Object.freeze({ + exporter: Object.freeze({ + command: "samchon-graph-go", + override: "SAMCHON_GRAPH_GO", + }), + toolchain: Object.freeze({ + command: "go", + override: "SAMCHON_GRAPH_GO_TOOLCHAIN", + }), + corroborator: Object.freeze({ + command: "scip-go", + override: "SAMCHON_GRAPH_SCIP_GO", + }), +}); + +const GO_GRAPH_RESOLUTION = Object.freeze({ + commands: Object.freeze( + Object.values(GO_GRAPH_TOOLS).map((tool) => tool.command), + ), + environmentOverrides: Object.freeze( + Object.values(GO_GRAPH_TOOLS).map((tool) => tool.override), + ), +}) satisfies IGraphProvider.IResolution; + function goIndexArgs(artifact: string): string[] { return [`--output=${artifact}`]; } @@ -47,6 +71,7 @@ export const goGraphProvider = Object.assign( "tests", "references", ] satisfies readonly GraphEdgeKind[], + resolution: GO_GRAPH_RESOLUTION, buildInputs: goBuildInputs, resolve: resolveGoGraphCommand, indexArgs: goIndexArgs, @@ -82,8 +107,7 @@ function resolveGoGraphCommand( env: NodeJS.ProcessEnv, ): IGraphProvider.ICommand | undefined { const installed = resolveProviderCommand(root, env, { - command: "samchon-graph-go", - override: "SAMCHON_GRAPH_GO", + ...GO_GRAPH_TOOLS.exporter, }); if (installed !== undefined) { return spawnableCommand.append( @@ -94,8 +118,7 @@ function resolveGoGraphCommand( const source = path.resolve(__dirname, "..", "..", "..", "sidecars", "go"); if (!fs.existsSync(path.join(source, "go.mod"))) return undefined; const go = resolveProviderCommand(root, env, { - command: "go", - override: "SAMCHON_GRAPH_GO_TOOLCHAIN", + ...GO_GRAPH_TOOLS.toolchain, }); return go === undefined ? undefined @@ -237,16 +260,15 @@ function goConfigurationDerivation( toolchainVersion.observe({ root, env, - command: "go", - override: "SAMCHON_GRAPH_GO_TOOLCHAIN", + ...GO_GRAPH_TOOLS.toolchain, args: ["env", "-json", ...GO_PROBED_ENVIRONMENT_KEYS], label: "go-env", }), toolObservation( root, env, - "scip-go", - "SAMCHON_GRAPH_SCIP_GO", + GO_GRAPH_TOOLS.corroborator.command, + GO_GRAPH_TOOLS.corroborator.override, ["--version"], ), ]); @@ -334,8 +356,8 @@ const GO_ENVIRONMENT_KEYS: readonly string[] = [ "GOTOOLCHAIN", "GOWORK", "PATH", - "SAMCHON_GRAPH_SCIP_GO", - "SAMCHON_GRAPH_GO_TOOLCHAIN", + GO_GRAPH_TOOLS.corroborator.override, + GO_GRAPH_TOOLS.toolchain.override, "PKG_CONFIG", ]; diff --git a/packages/graph/src/provider/graphCoverageOf.ts b/packages/graph/src/provider/graphCoverageOf.ts new file mode 100644 index 00000000..fc475917 --- /dev/null +++ b/packages/graph/src/provider/graphCoverageOf.ts @@ -0,0 +1,24 @@ +import { ISamchonGraphCoverage } from "../structures"; +import { IBulkGraphSession } from "./IBulkGraphSession"; +import { coverageRows } from "./coverageRows"; + +/** + * Normalize one strict snapshot to an exhaustive coverage matrix. + * + * Protocol-aware producers publish their exact rows. Legacy strict producers + * are deliberately conservative during migration: a registered family is + * `partial`, never silently `complete`, and every other family is + * `unsupported`. + */ +export function graphCoverageOf( + snapshot: IBulkGraphSession.ISnapshot, +): ISamchonGraphCoverage[] { + return snapshot.coverage === undefined + ? coverageRows( + snapshot.provenance.provider, + snapshot.languages, + snapshot.provenance.universe, + new Set(snapshot.provenance.facts), + ) + : snapshot.coverage.map((row) => ({ ...row })); +} diff --git a/packages/graph/src/provider/graphSnapshotDigests.ts b/packages/graph/src/provider/graphSnapshotDigests.ts index be84786a..37053d80 100644 --- a/packages/graph/src/provider/graphSnapshotDigests.ts +++ b/packages/graph/src/provider/graphSnapshotDigests.ts @@ -1,5 +1,7 @@ import { createHash } from "node:crypto"; +import { graphCoverageOf } from "./graphCoverageOf"; +import { graphUnresolvedOf } from "./graphUnresolvedOf"; import { IBulkGraphSession } from "./IBulkGraphSession"; /** @@ -54,6 +56,12 @@ export namespace graphSnapshotDigests { for (const diagnostic of snapshot.diagnostics) { hash.update(`diagnostic\0${canonical(diagnostic)}\n`); } + for (const coverage of graphCoverageOf(snapshot)) { + hash.update(`coverage\0${canonical(coverage)}\n`); + } + for (const unresolved of graphUnresolvedOf(snapshot)) { + hash.update(`unresolved\0${canonical(unresolved)}\n`); + } return hash.digest("hex"); } @@ -77,8 +85,11 @@ export namespace graphSnapshotDigests { nodes: snapshot.nodes, edges: snapshot.edges, diagnostics: snapshot.diagnostics, + coverage: graphCoverageOf(snapshot), + unresolved: graphUnresolvedOf(snapshot), sources, provenance: snapshot.provenance, + protocol: snapshot.protocol, warnings: snapshot.warnings, }), ) diff --git a/packages/graph/src/provider/graphUnresolvedOf.ts b/packages/graph/src/provider/graphUnresolvedOf.ts new file mode 100644 index 00000000..199ff2d2 --- /dev/null +++ b/packages/graph/src/provider/graphUnresolvedOf.ts @@ -0,0 +1,15 @@ +import { ISamchonGraphUnresolved } from "../structures"; +import { IBulkGraphSession } from "./IBulkGraphSession"; + +/** Structured uncertainty retained from a protocol-aware producer. */ +export function graphUnresolvedOf( + snapshot: IBulkGraphSession.ISnapshot, +): ISamchonGraphUnresolved[] { + return (snapshot.unresolved ?? []).map((row) => ({ + ...row, + evidence: { ...row.evidence }, + ...(row.candidates !== undefined + ? { candidates: [...row.candidates] } + : {}), + })); +} diff --git a/packages/graph/src/provider/index.ts b/packages/graph/src/provider/index.ts index 3d5a7e54..6a1470c0 100644 --- a/packages/graph/src/provider/index.ts +++ b/packages/graph/src/provider/index.ts @@ -3,6 +3,11 @@ export * from "./dumpProvenanceOf"; export * from "./BatchGraphSession"; export * from "./GRAPH_PROVIDERS"; export * from "./graphSnapshotDigests"; +export * from "./fallbackCoverage"; +export * from "./graphCoverageOf"; +export * from "./graphUnresolvedOf"; +export * from "./GraphSnapshotProtocol"; +export * from "./cpp"; export * from "./go"; export * from "./IBulkGraphSession"; export * from "./IGraphProvider"; diff --git a/packages/graph/src/provider/lua/luaGraphProvider.ts b/packages/graph/src/provider/lua/luaGraphProvider.ts index 12d9f011..70c5f207 100644 --- a/packages/graph/src/provider/lua/luaGraphProvider.ts +++ b/packages/graph/src/provider/lua/luaGraphProvider.ts @@ -11,6 +11,20 @@ import { LuaGraphSession } from "./LuaGraphSession"; const BUILD_FILES = [".luarc.json", ".luarc.jsonc"] as const; const BUILD_EXTENSIONS = [".rockspec"] as const; +const LUA_GRAPH_TOOLS = Object.freeze({ + server: Object.freeze({ + command: "lua-language-server", + override: "SAMCHON_GRAPH_LUA", + }), + exporterOverride: "SAMCHON_GRAPH_LUA_EXPORTER", +}); +const LUA_GRAPH_RESOLUTION = Object.freeze({ + commands: Object.freeze([LUA_GRAPH_TOOLS.server.command]), + environmentOverrides: Object.freeze([ + LUA_GRAPH_TOOLS.server.override, + LUA_GRAPH_TOOLS.exporterOverride, + ]), +}) satisfies IGraphProvider.IResolution; /** * Lua, indexed by driving lua-language-server's own analysis engine. @@ -30,6 +44,7 @@ export const luaGraphProvider: IGraphProvider = { languages: ["lua"], authority: "analyzer", facts: [...LuaGraphSession.FACTS], + resolution: LUA_GRAPH_RESOLUTION, buildInputs: (root) => providerInputFiles(root, [], BUILD_FILES, BUILD_EXTENSIONS), @@ -65,8 +80,7 @@ export const luaGraphProvider: IGraphProvider = { resolve: (root, env) => { if (inspectExporter(env).status !== "available") return undefined; return resolveProviderCommand(root, env, { - command: "lua-language-server", - override: "SAMCHON_GRAPH_LUA", + ...LUA_GRAPH_TOOLS.server, }); }, @@ -140,8 +154,7 @@ function luaConfiguration( toolchainVersion.observe({ root, env, - command: "lua-language-server", - override: "SAMCHON_GRAPH_LUA", + ...LUA_GRAPH_TOOLS.server, args: ["--version"], ...(resolved === undefined ? {} : { resolved }), }), @@ -187,7 +200,7 @@ function luaExporterConfiguration( * tool look away. */ function inspectExporter(env: NodeJS.ProcessEnv): IExporterInspection { - const named = env.SAMCHON_GRAPH_LUA_EXPORTER; + const named = env[LUA_GRAPH_TOOLS.exporterOverride]; const script = named !== undefined && named !== "" ? path.resolve(named) diff --git a/packages/graph/src/provider/rust/IRustGraphCacheState.ts b/packages/graph/src/provider/rust/IRustGraphCacheState.ts new file mode 100644 index 00000000..31ded2df --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphCacheState.ts @@ -0,0 +1,11 @@ +import type { GraphSnapshotProtocol } from "../GraphSnapshotProtocol"; +import type { IRustGraphCheckpoint } from "./IRustGraphCheckpoint"; +import type { IRustGraphShard } from "./IRustGraphShard"; + +export interface IRustGraphCacheState { + version: 1; + producerCommit: string; + checkpoint: IRustGraphCheckpoint; + rawShards: IRustGraphShard[]; + frames: GraphSnapshotProtocol.Frame[]; +} diff --git a/packages/graph/src/provider/rust/IRustGraphCheckpoint.ts b/packages/graph/src/provider/rust/IRustGraphCheckpoint.ts new file mode 100644 index 00000000..fa9abc26 --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphCheckpoint.ts @@ -0,0 +1,15 @@ +import type { IRustGraphCheckpointSource } from "./IRustGraphCheckpointSource"; +import type { IRustGraphManifestEntry } from "./IRustGraphManifestEntry"; +import type { IRustGraphProducer } from "./IRustGraphProducer"; +import type { IRustGraphShard } from "./IRustGraphShard"; + +export interface IRustGraphCheckpoint { + protocolVersion: number; + schemaVersion: number; + producer: IRustGraphProducer; + universe: string; + generation: string; + manifest: IRustGraphManifestEntry[]; + sources: IRustGraphCheckpointSource[]; + shards: IRustGraphShard[]; +} diff --git a/packages/graph/src/provider/rust/IRustGraphCheckpointSource.ts b/packages/graph/src/provider/rust/IRustGraphCheckpointSource.ts new file mode 100644 index 00000000..daeafad7 --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphCheckpointSource.ts @@ -0,0 +1,4 @@ +export interface IRustGraphCheckpointSource { + source: string; + checkerDigest: string; +} diff --git a/packages/graph/src/provider/rust/IRustGraphCoverage.ts b/packages/graph/src/provider/rust/IRustGraphCoverage.ts new file mode 100644 index 00000000..6166e880 --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphCoverage.ts @@ -0,0 +1,4 @@ +export interface IRustGraphCoverage { + family: string; + state: string; +} diff --git a/packages/graph/src/provider/rust/IRustGraphDiagnostic.ts b/packages/graph/src/provider/rust/IRustGraphDiagnostic.ts new file mode 100644 index 00000000..b3a09970 --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphDiagnostic.ts @@ -0,0 +1,8 @@ +export interface IRustGraphDiagnostic { + file: string; + line: number; + column: number | null; + code: string; + message: string; + severity: string | null; +} diff --git a/packages/graph/src/provider/rust/IRustGraphEdge.ts b/packages/graph/src/provider/rust/IRustGraphEdge.ts new file mode 100644 index 00000000..8a33893a --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphEdge.ts @@ -0,0 +1,8 @@ +import type { IRustGraphEvidence } from "./IRustGraphEvidence"; + +export interface IRustGraphEdge { + from: string; + to: string; + kind: string; + evidence: IRustGraphEvidence | null; +} diff --git a/packages/graph/src/provider/rust/IRustGraphEvidence.ts b/packages/graph/src/provider/rust/IRustGraphEvidence.ts new file mode 100644 index 00000000..c71e7e3c --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphEvidence.ts @@ -0,0 +1,7 @@ +export interface IRustGraphEvidence { + file: string; + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; +} diff --git a/packages/graph/src/provider/rust/IRustGraphManifestEntry.ts b/packages/graph/src/provider/rust/IRustGraphManifestEntry.ts new file mode 100644 index 00000000..53999a19 --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphManifestEntry.ts @@ -0,0 +1,4 @@ +export interface IRustGraphManifestEntry { + key: string; + digest: string; +} diff --git a/packages/graph/src/provider/rust/IRustGraphNode.ts b/packages/graph/src/provider/rust/IRustGraphNode.ts new file mode 100644 index 00000000..23fd898b --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphNode.ts @@ -0,0 +1,13 @@ +import type { IRustGraphEvidence } from "./IRustGraphEvidence"; + +export interface IRustGraphNode { + id: string; + kind: string; + name: string; + qualifiedName: string | null; + file: string; + external: boolean; + exported: boolean; + signature: string | null; + evidence: IRustGraphEvidence | null; +} diff --git a/packages/graph/src/provider/rust/IRustGraphPhases.ts b/packages/graph/src/provider/rust/IRustGraphPhases.ts new file mode 100644 index 00000000..d09514ea --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphPhases.ts @@ -0,0 +1,7 @@ +export interface IRustGraphPhases { + semanticMillis: number; + shardMillis: number; + encodeMillis: number; + totalMillis: number; + cacheHit: boolean; +} diff --git a/packages/graph/src/provider/rust/IRustGraphProducer.ts b/packages/graph/src/provider/rust/IRustGraphProducer.ts new file mode 100644 index 00000000..26e938d1 --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphProducer.ts @@ -0,0 +1,5 @@ +export interface IRustGraphProducer { + name: string; + version: string; + commit: string; +} diff --git a/packages/graph/src/provider/rust/IRustGraphShard.ts b/packages/graph/src/provider/rust/IRustGraphShard.ts new file mode 100644 index 00000000..c73e9c9d --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphShard.ts @@ -0,0 +1,27 @@ +import type { IRustGraphCoverage } from "./IRustGraphCoverage"; +import type { IRustGraphDiagnostic } from "./IRustGraphDiagnostic"; +import type { IRustGraphEdge } from "./IRustGraphEdge"; +import type { IRustGraphEvidence } from "./IRustGraphEvidence"; +import type { IRustGraphNode } from "./IRustGraphNode"; + +export interface IRustGraphShard { + key: string; + source: string; + checkerDigest: string; + interfaceFingerprint: string; + digest: string; + nodes: IRustGraphNode[]; + edges: IRustGraphEdge[]; + diagnostics: IRustGraphDiagnostic[]; + coverage: IRustGraphCoverage[]; + unresolved: IRustGraphShard.Unresolved[]; +} + +export declare namespace IRustGraphShard { + export interface Unresolved { + family: string; + evidence: IRustGraphEvidence; + reason: string; + candidates: string[]; + } +} diff --git a/packages/graph/src/provider/rust/IRustGraphSnapshot.ts b/packages/graph/src/provider/rust/IRustGraphSnapshot.ts new file mode 100644 index 00000000..1ca58ef6 --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphSnapshot.ts @@ -0,0 +1,19 @@ +import type { IRustGraphManifestEntry } from "./IRustGraphManifestEntry"; +import type { IRustGraphPhases } from "./IRustGraphPhases"; +import type { IRustGraphProducer } from "./IRustGraphProducer"; +import type { IRustGraphShard } from "./IRustGraphShard"; +import type { IRustGraphUniverse } from "./IRustGraphUniverse"; + +export interface IRustGraphSnapshot { + protocolVersion: number; + schemaVersion: number; + producer: IRustGraphProducer; + universe: IRustGraphUniverse; + sequence: number; + generation: string; + baseGeneration: string | null; + upserts: IRustGraphShard[]; + deletes: string[]; + manifest: IRustGraphManifestEntry[]; + phases: IRustGraphPhases; +} diff --git a/packages/graph/src/provider/rust/IRustGraphSnapshotParams.ts b/packages/graph/src/provider/rust/IRustGraphSnapshotParams.ts new file mode 100644 index 00000000..b55901ff --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphSnapshotParams.ts @@ -0,0 +1,6 @@ +import type { IRustGraphCheckpoint } from "./IRustGraphCheckpoint"; + +export interface IRustGraphSnapshotParams { + knownGeneration?: string; + checkpoint?: IRustGraphCheckpoint; +} diff --git a/packages/graph/src/provider/rust/IRustGraphUniverse.ts b/packages/graph/src/provider/rust/IRustGraphUniverse.ts new file mode 100644 index 00000000..c7506abc --- /dev/null +++ b/packages/graph/src/provider/rust/IRustGraphUniverse.ts @@ -0,0 +1,7 @@ +export interface IRustGraphUniverse { + digest: string; + target: string; + workspaceRoots: string[]; + toolchains: string[]; + configurations: string[]; +} diff --git a/packages/graph/src/provider/rust/RUST_GRAPH_PRODUCER_COMMIT.ts b/packages/graph/src/provider/rust/RUST_GRAPH_PRODUCER_COMMIT.ts new file mode 100644 index 00000000..eceecb30 --- /dev/null +++ b/packages/graph/src/provider/rust/RUST_GRAPH_PRODUCER_COMMIT.ts @@ -0,0 +1,2 @@ +export const RUST_GRAPH_PRODUCER_COMMIT = + "2850ecba80311bebd4cdaa9fedc5321533b5b1e7"; diff --git a/packages/graph/src/provider/rust/RUST_HIR_FACTS.ts b/packages/graph/src/provider/rust/RUST_HIR_FACTS.ts new file mode 100644 index 00000000..1fd5f202 --- /dev/null +++ b/packages/graph/src/provider/rust/RUST_HIR_FACTS.ts @@ -0,0 +1,5 @@ +import { GRAPH_EDGE_KINDS, GraphEdgeKind } from "../../typings"; + +export const RUST_HIR_FACTS: readonly GraphEdgeKind[] = GRAPH_EDGE_KINDS.filter( + (kind) => kind !== "renders", +); diff --git a/packages/graph/src/provider/rust/RUST_HIR_PRODUCER.ts b/packages/graph/src/provider/rust/RUST_HIR_PRODUCER.ts new file mode 100644 index 00000000..85443d87 --- /dev/null +++ b/packages/graph/src/provider/rust/RUST_HIR_PRODUCER.ts @@ -0,0 +1 @@ +export const RUST_HIR_PRODUCER = "samchon-rust-analyzer"; diff --git a/packages/graph/src/provider/rust/RUST_HIR_PROVIDER.ts b/packages/graph/src/provider/rust/RUST_HIR_PROVIDER.ts new file mode 100644 index 00000000..1e6ec327 --- /dev/null +++ b/packages/graph/src/provider/rust/RUST_HIR_PROVIDER.ts @@ -0,0 +1 @@ +export const RUST_HIR_PROVIDER = "samchon-rust-analyzer-hir"; diff --git a/packages/graph/src/provider/rust/RustGraphCache.ts b/packages/graph/src/provider/rust/RustGraphCache.ts new file mode 100644 index 00000000..9667445c --- /dev/null +++ b/packages/graph/src/provider/rust/RustGraphCache.ts @@ -0,0 +1,198 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { GraphSnapshotProtocol } from "../GraphSnapshotProtocol"; +import { IRustGraphCacheState } from "./IRustGraphCacheState"; + +const CACHE_VERSION = 1; +const MAX_CACHE_BYTES = 512 * 1024 * 1024; +const RETAINED_GENERATIONS = 2; +const GENERATION = /^[a-f0-9]{64}$/u; + +export namespace RustGraphCache { + export function load( + props: IProps, + accept: (state: IRustGraphCacheState) => boolean = () => true, + ): IRustGraphCacheState | undefined { + const directory = projectDirectory(props); + let files: string[]; + try { + files = fs + .readdirSync(directory) + .filter((file) => /^\d+-[a-f0-9]{64}\.json$/u.test(file)) + .sort((left, right) => sequenceOf(right) - sequenceOf(left)); + } catch { + return undefined; + } + for (const file of files) { + try { + const coordinates = coordinatesOf(file); + if (coordinates === undefined) continue; + const absolute = path.join(directory, file); + const size = fs.statSync(absolute).size; + if (size < 1 || size > MAX_CACHE_BYTES) continue; + const parsed = JSON.parse(fs.readFileSync(absolute, "utf8")) as IRustGraphCacheState; + if ( + parsed.version === CACHE_VERSION && + parsed.producerCommit === props.producerCommit && + Array.isArray(parsed.frames) && + Array.isArray(parsed.rawShards) && + parsed.checkpoint !== null && + typeof parsed.checkpoint === "object" && + parsed.checkpoint.generation === coordinates.generation && + isMatchingCommitFrame( + parsed.frames.at(-1), + coordinates.sequence, + coordinates.generation, + ) + ) { + if (accept(parsed)) return parsed; + } + } catch { + // A torn or obsolete cache generation is not evidence. Try the prior + // immutable generation and let the live producer validate any winner. + } + } + return undefined; + } + + export function save( + props: IProps, + sequence: number, + generation: string, + state: IRustGraphCacheState, + ): void { + if ( + !Number.isSafeInteger(sequence) || + sequence < 1 || + !GENERATION.test(generation) || + state.checkpoint.generation !== generation || + !isMatchingCommitFrame(state.frames.at(-1), sequence, generation) + ) { + throw new Error("rust HIR graph: invalid persisted generation coordinates"); + } + const encoded = JSON.stringify(state); + /* c8 ignore start -- exercising the hard 512 MiB corruption guard would + * allocate a fixture larger than the test process's bounded heap. */ + if (Buffer.byteLength(encoded, "utf8") > MAX_CACHE_BYTES) { + throw new Error("rust HIR graph: persisted generation exceeds the cache size limit"); + } + /* c8 ignore stop */ + const directory = projectDirectory(props); + fs.mkdirSync(directory, { recursive: true }); + const file = path.join(directory, `${String(sequence)}-${generation}.json`); + if (!fs.existsSync(file)) { + const temporary = path.join( + directory, + `.${String(process.pid)}-${String(sequence)}-${generation}.tmp`, + ); + fs.writeFileSync(temporary, encoded, { + encoding: "utf8", + flag: "wx", + }); + try { + fs.renameSync(temporary, file); + } catch (error) { + if (!fs.existsSync(file)) throw error; + fs.rmSync(temporary); + } + } + const obsolete = fs + .readdirSync(directory) + .filter((entry) => /^\d+-[a-f0-9]{64}\.json$/u.test(entry)) + .sort((left, right) => sequenceOf(right) - sequenceOf(left)) + .slice(RETAINED_GENERATIONS); + for (const entry of obsolete) fs.rmSync(path.join(directory, entry)); + } + + export function clear(props: IProps): void { + const directory = projectDirectory(props); + let entries: string[]; + try { + entries = fs.readdirSync(directory); + } catch { + return; + } + for (const entry of entries) { + if ( + /^\d+-[a-f0-9]{64}\.json$/u.test(entry) || + /^\.\d+-\d+-[a-f0-9]{64}\.tmp$/u.test(entry) + ) { + fs.rmSync(path.join(directory, entry)); + } + } + } + + export interface IProps { + root: string; + producerCommit: string; + cacheRoot?: string; + } +} + +function projectDirectory(props: RustGraphCache.IProps): string { + const root = path.resolve(props.root); + /* c8 ignore start -- coverage runs on one host platform; Windows folds the + * cache identity and POSIX preserves it. */ + const cacheIdentity = process.platform === "win32" ? root.toLowerCase() : root; + /* c8 ignore stop */ + const key = createHash("sha256") + .update(cacheIdentity) + .digest("hex"); + return path.join( + props.cacheRoot ?? defaultCacheRoot(), + "rust", + props.producerCommit, + key, + ); +} + +function defaultCacheRoot(): string { + const configured = process.env.SAMCHON_GRAPH_CACHE_DIR; + if (configured !== undefined && path.isAbsolute(configured)) return configured; + /* c8 ignore start -- this branch is executable only on Windows; the Windows + * CI lane exercises it while POSIX coverage cannot change process.platform. */ + if (process.platform === "win32") { + const local = process.env.LOCALAPPDATA; + if (local !== undefined && path.isAbsolute(local)) { + return path.join(local, "samchon-graph"); + } + } + /* c8 ignore stop */ + const xdg = process.env.XDG_CACHE_HOME; + if (xdg !== undefined && path.isAbsolute(xdg)) { + return path.join(xdg, "samchon-graph"); + } + return path.join(os.homedir(), ".cache", "samchon-graph"); +} + +function sequenceOf(file: string): number { + return Number(file.slice(0, file.indexOf("-"))); +} + +function coordinatesOf( + file: string, +): { sequence: number; generation: string } | undefined { + const separator = file.indexOf("-"); + const sequence = Number(file.slice(0, separator)); + const generation = file.slice(separator + 1, -".json".length); + return Number.isSafeInteger(sequence) && sequence >= 1 && GENERATION.test(generation) + ? { sequence, generation } + : undefined; +} + +function isMatchingCommitFrame( + value: unknown, + sequence: number, + generation: string, +): boolean { + if (value === null || typeof value !== "object") return false; + const frame = value as { type?: unknown; sequence?: unknown; generation?: unknown }; + return ( + frame.type === "commit" && + frame.sequence === sequence && + frame.generation === generation + ); +} diff --git a/packages/graph/src/provider/rust/RustGraphClient.ts b/packages/graph/src/provider/rust/RustGraphClient.ts new file mode 100644 index 00000000..d3156e9d --- /dev/null +++ b/packages/graph/src/provider/rust/RustGraphClient.ts @@ -0,0 +1,397 @@ +import { pathToFileURL } from "node:url"; + +import { GraphLanguage } from "../../typings"; +import { LspClient } from "../../lsp/LspClient"; +import { LspResponseError } from "../../lsp/LspResponseError"; +import { GraphSnapshotProtocol } from "../GraphSnapshotProtocol"; +import { IBulkGraphSession } from "../IBulkGraphSession"; +import { IRustGraphSnapshot } from "./IRustGraphSnapshot"; +import { IRustGraphSnapshotParams } from "./IRustGraphSnapshotParams"; +import { RustGraphCache } from "./RustGraphCache"; +import { RustGraphSnapshotAdapter } from "./RustGraphSnapshotAdapter"; + +const GRAPH_METHOD = "samchon/graphSnapshot"; +const SERVER_CANCELLED = -32802; +const CONTENT_MODIFIED = -32801; +const DEFAULT_READY_TIMEOUT_MS = 300_000; +const RETRY_DELAY_MS = 50; +const MAX_RETRY_DELAY_MS = 5_000; + +/** Resident LSP client for the pinned HIR graphSnapshot producer. */ +export class RustGraphClient implements IBulkGraphSession { + public readonly kind = "bulk" as const; + public readonly languages: readonly GraphLanguage[] = ["rust"]; + public readonly root: string; + + private readonly lsp: LspClient; + private adapter: RustGraphSnapshotAdapter; + private readonly cache: RustGraphCache.IProps; + private readonly validate: ( + snapshot: IBulkGraphSession.ISnapshot, + ) => void; + private readonly initializationOptions: unknown; + private readonly requestTimeoutMs: number | undefined; + private readonly readyTimeoutMs: number; + private readonly lifecycleAbort = new AbortController(); + private queue: Promise = Promise.resolve(); + private initialized: Promise | undefined; + private checkpointPending = false; + private version: number; + private closed = false; + private closing: Promise | undefined; + + public constructor(options: RustGraphClient.IOptions) { + this.root = options.root; + this.validate = options.validate ?? (() => undefined); + this.cache = { + root: options.root, + producerCommit: options.producerCommit, + ...(options.cacheRoot === undefined + ? {} + : { cacheRoot: options.cacheRoot }), + }; + let restored: RustGraphSnapshotAdapter | undefined; + const cached = RustGraphCache.load(this.cache, (state) => { + const candidate = new RustGraphSnapshotAdapter( + options.root, + options.producerCommit, + state, + ); + restored = candidate; + return true; + }); + if (cached === undefined || restored === undefined) { + RustGraphCache.clear(this.cache); + this.adapter = new RustGraphSnapshotAdapter( + options.root, + options.producerCommit, + ); + } else { + this.adapter = restored; + } + this.checkpointPending = this.adapter.persistedCheckpoint !== undefined; + this.version = 0; + this.initializationOptions = options.initializationOptions; + this.requestTimeoutMs = options.requestTimeoutMs; + this.readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS; + /* c8 ignore start -- production native binaries need no arguments; the + * protocol fixture itself is a JavaScript file and therefore needs one. */ + const args = options.args ?? []; + /* c8 ignore stop */ + this.lsp = new LspClient( + options.command, + args, + options.requestTimeoutMs, + options.root, + options.maxMessageBytes, + options.windowsVerbatimArguments, + undefined, + serverRequest, + ); + } + + public get generation(): number { + return this.version; + } + + public get current(): IBulkGraphSession.ISnapshot | undefined { + return this.adapter.store.current; + } + + public refresh( + options: { signal?: AbortSignal } = {}, + ): Promise { + if (this.closed) { + return Promise.reject(new Error("rust HIR graph: session is closed")); + } + return this.enqueue(async () => { + const signal = combineSignals(options.signal, this.lifecycleAbort.signal); + this.assertOpen(); + await this.initialize(signal); + const raw = await this.requestSnapshot(signal); + const prepared = this.adapter.prepare(raw); + if (!prepared.changed) { + return { + changed: false, + generation: this.version, + mode: prepared.mode, + snapshot: prepared.snapshot, + }; + } + new GraphSnapshotProtocol.Store(this.root).apply(prepared.state.frames, { + signal, + validate: this.validate, + }); + const warnings: string[] = []; + try { + RustGraphCache.save( + this.cache, + prepared.sequence, + prepared.generation, + prepared.state, + ); + } catch (error) { + warnings.push( + `rust HIR graph: the validated snapshot is resident but its restart checkpoint could not be persisted: ${asError(error).message}`, + ); + } + const snapshot = this.adapter.store.apply(prepared.frames, { + signal, + validate: this.validate, + warnings, + }); + prepared.commit(snapshot); + this.version += 1; + return { + changed: true, + generation: this.version, + mode: prepared.mode, + snapshot, + }; + }, options.signal); + } + + public close(): Promise { + if (this.closing !== undefined) return this.closing; + this.closed = true; + this.lifecycleAbort.abort(new Error("rust HIR graph: session is closed")); + this.closing = this.lsp.close(); + return this.closing; + } + + private initialize(signal: AbortSignal): Promise { + this.initialized ??= this.initializeOnce(this.lifecycleAbort.signal); + return signal === this.lifecycleAbort.signal + ? this.initialized + : raceWithAbort(this.initialized, signal); + } + + private async initializeOnce(signal: AbortSignal): Promise { + await this.lsp.request( + "initialize", + { + processId: process.pid, + rootUri: pathToFileURL(this.root).href, + capabilities: { workspace: { configuration: true } }, + ...(this.initializationOptions === undefined + ? {} + : { initializationOptions: this.initializationOptions }), + workspaceFolders: [ + { + uri: pathToFileURL(this.root).href, + name: "samchon-graph-rust", + }, + ], + }, + this.requestTimeoutMs, + signal, + ); + this.lsp.notify("initialized", {}); + } + + private async requestSnapshot(signal: AbortSignal): Promise { + const deadline = performance.now() + this.readyTimeoutMs; + let checkpoint = this.checkpointPending + ? this.adapter.persistedCheckpoint + : undefined; + this.checkpointPending = false; + let backoff = RETRY_DELAY_MS; + for (;;) { + throwIfAborted(signal); + const params: IRustGraphSnapshotParams = { + ...(this.adapter.persistedCheckpoint?.generation === undefined + ? {} + : { + knownGeneration: + this.adapter.persistedCheckpoint.generation, + }), + ...(checkpoint === undefined ? {} : { checkpoint }), + }; + try { + return await this.lsp.request( + GRAPH_METHOD, + params, + this.requestTimeoutMs, + signal, + ); + } catch (error) { + if ( + checkpoint !== undefined && + error instanceof LspResponseError && + error.code === SERVER_CANCELLED && + /checkpoint|persisted/iu.test(error.message) + ) { + this.adapter.discardPersistedSnapshot(); + RustGraphCache.clear(this.cache); + checkpoint = undefined; + continue; + } + if ( + !(error instanceof LspResponseError) || + (error.code !== SERVER_CANCELLED && error.code !== CONTENT_MODIFIED) + ) { + throw error; + } + if (performance.now() >= deadline) { + throw new Error( + `rust HIR graph: producer did not become ready within ${String(this.readyTimeoutMs)} ms: ${error.message}`, + ); + } + await delay( + Math.min(backoff, Math.max(0, deadline - performance.now())), + signal, + ); + // Same backoff, same clamp and same reset as the Clang client, for the + // same reasons written out there. This lane has never demonstrated the + // problem — rust-analyzer becomes ready quickly on the pinned corpus, + // and its row runs on the default timeout — but the loop is the same + // shape, so it should not be the one left to find out on a larger + // workspace. + backoff = + error.code === CONTENT_MODIFIED + ? RETRY_DELAY_MS + : Math.min(backoff * 2, MAX_RETRY_DELAY_MS); + } + } + } + + private assertOpen(): void { + if (this.closed) throw new Error("rust HIR graph: session is closed"); + } + + private enqueue( + task: () => Promise, + signal?: AbortSignal, + ): Promise { + let resolveResult!: (value: T) => void; + let rejectResult!: (error: Error) => void; + let started = false; + let settled = false; + const result = new Promise((resolve, reject) => { + resolveResult = (value) => { + settled = true; + resolve(value); + }; + rejectResult = (error) => { + settled = true; + reject(error); + }; + }); + const cancelQueued = (): void => { + if (!started) rejectResult(cancelledError(signal)); + }; + if (signal?.aborted) { + rejectResult(cancelledError(signal)); + return result; + } + signal?.addEventListener("abort", cancelQueued, { once: true }); + this.queue = this.queue + .catch(() => undefined) + .then(async () => { + started = true; + signal?.removeEventListener("abort", cancelQueued); + if (settled) return; + try { + resolveResult(await task()); + } catch (error) { + rejectResult(asError(error)); + } + }); + return result; + } +} + +export namespace RustGraphClient { + export interface IOptions { + root: string; + command: string; + args?: readonly string[]; + producerCommit: string; + initializationOptions?: unknown; + requestTimeoutMs?: number; + readyTimeoutMs?: number; + maxMessageBytes?: number; + windowsVerbatimArguments?: boolean; + cacheRoot?: string; + validate?: (snapshot: IBulkGraphSession.ISnapshot) => void; + } +} + +function serverRequest(method: string, params: unknown): unknown { + if (method !== "workspace/configuration") return null; + const items = (params as { items?: unknown })?.items; + return Array.isArray(items) ? items.map(() => null) : []; +} + +function delay(milliseconds: number, signal: AbortSignal): Promise { + /* c8 ignore start -- requestSnapshot checks this signal immediately before + * entering backoff; this closes only the intervening abort race. */ + if (signal.aborted) return Promise.reject(cancelledError(signal)); + /* c8 ignore stop */ + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", abort); + resolve(undefined); + }, milliseconds); + timer.unref?.(); + const abort = (): void => { + clearTimeout(timer); + signal.removeEventListener("abort", abort); + reject(cancelledError(signal)); + }; + signal.addEventListener("abort", abort, { once: true }); + }); +} + +function combineSignals( + caller: AbortSignal | undefined, + lifecycle: AbortSignal, +): AbortSignal { + return caller === undefined ? lifecycle : AbortSignal.any([caller, lifecycle]); +} + +function raceWithAbort(task: Promise, signal: AbortSignal): Promise { + /* c8 ignore start -- enqueue rejects pre-aborted callers before a task can + * reach this initialization boundary. */ + if (signal.aborted) return Promise.reject(cancelledError(signal)); + /* c8 ignore stop */ + return new Promise((resolve, reject) => { + const abort = (): void => { + signal.removeEventListener("abort", abort); + reject(cancelledError(signal)); + }; + signal.addEventListener("abort", abort, { once: true }); + void task + .then((value) => { + signal.removeEventListener("abort", abort); + resolve(value); + }) + .catch((error: unknown) => { + signal.removeEventListener("abort", abort); + reject(error); + }); + }); +} + +function throwIfAborted(signal: AbortSignal): void { + /* c8 ignore start -- queue cancellation and the LSP request fence exercise + * deterministic aborts; this is the instruction-boundary race guard. */ + if (signal.aborted) throw cancelledError(signal); + /* c8 ignore stop */ +} + +function cancelledError(signal?: AbortSignal): Error { + /* c8 ignore start -- standards-compliant AbortSignal.abort() always + * supplies a reason; optionality protects foreign signal shims. */ + const reason = signal?.reason === undefined ? "" : `: ${String(signal.reason)}`; + /* c8 ignore stop */ + const error = new Error( + `rust HIR graph: snapshot request cancelled${reason}`, + ); + error.name = "AbortError"; + return error; +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/packages/graph/src/provider/rust/RustGraphSnapshotAdapter.ts b/packages/graph/src/provider/rust/RustGraphSnapshotAdapter.ts new file mode 100644 index 00000000..803dd6e6 --- /dev/null +++ b/packages/graph/src/provider/rust/RustGraphSnapshotAdapter.ts @@ -0,0 +1,1005 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; + +import { + ISamchonGraphCoverage, + ISamchonGraphDiagnostic, + ISamchonGraphEdge, + ISamchonGraphEvidence, + ISamchonGraphNode, + ISamchonGraphUnresolved, +} from "../../structures"; +import { + GRAPH_EDGE_KINDS, + GraphEdgeKind, + GraphNodeKind, +} from "../../typings"; +import { IBulkGraphSession } from "../IBulkGraphSession"; +import { GraphSnapshotProtocol } from "../GraphSnapshotProtocol"; +import { semanticGraphNodeId } from "../semanticIdentity"; +import { IRustGraphCacheState } from "./IRustGraphCacheState"; +import { IRustGraphCheckpoint } from "./IRustGraphCheckpoint"; +import { IRustGraphCoverage } from "./IRustGraphCoverage"; +import { IRustGraphEvidence } from "./IRustGraphEvidence"; +import { IRustGraphNode } from "./IRustGraphNode"; +import { IRustGraphShard } from "./IRustGraphShard"; +import { IRustGraphSnapshot } from "./IRustGraphSnapshot"; +import { RUST_HIR_FACTS } from "./RUST_HIR_FACTS"; +import { RUST_HIR_PRODUCER } from "./RUST_HIR_PRODUCER"; +import { RUST_HIR_PROVIDER } from "./RUST_HIR_PROVIDER"; + +const DIGEST = /^[a-f0-9]{64}$/u; +const NODE_KINDS = new Set([ + "file", + "package", + "namespace", + "module", + "function", + "class", + "interface", + "type", + "enum", + "variable", + "method", + "property", + "parameter", + "field", + "constructor", +]); +const COVERAGE_STATES = new Set(["complete", "partial", "unsupported"]); +const DIAGNOSTIC_SEVERITIES = new Set(["error", "warning", "info", "hint"]); +const UNRESOLVED_REASONS = new Set([ + "dynamic", + "reflection", + "macro-or-generated", + "conditional-build", + "external-boundary", + "analysis-error", + "excluded-input", + "identity-unstable", + "provider-gap", +]); +const CAPABILITIES = [ + "coverage", + "diagnostics", + "diskDigests", + "incremental", + "sourceDigests", + "universe", + "unresolved", + "validatedConsumerCheckpoint", +]; + +export class RustGraphSnapshotAdapter { + public store: GraphSnapshotProtocol.Store; + private rawShards = new Map(); + private graphShards = new Map(); + private rawGeneration: string | undefined; + private checkpoint: IRustGraphCheckpoint | undefined; + private restoringCheckpoint = false; + + public constructor( + private readonly root: string, + private readonly producerCommit: string, + cached?: IRustGraphCacheState, + ) { + this.store = new GraphSnapshotProtocol.Store(root); + if (cached !== undefined) this.restore(cached); + } + + public get persistedCheckpoint(): IRustGraphCheckpoint | undefined { + return this.checkpoint === undefined + ? undefined + : structuredClone(this.checkpoint); + } + + public get hasPersistedSnapshot(): boolean { + return this.store.current !== undefined; + } + + public discardPersistedSnapshot(): void { + if (this.rawGeneration === undefined) return; + this.rawShards.clear(); + this.graphShards.clear(); + this.rawGeneration = undefined; + this.checkpoint = undefined; + this.restoringCheckpoint = false; + this.store = new GraphSnapshotProtocol.Store(this.root); + } + + public prepare( + raw: IRustGraphSnapshot, + ): RustGraphSnapshotAdapter.IPrepared { + assertSnapshot(raw, this.producerCommit); + const prior = this.store.current; + const priorRawGeneration = this.rawGeneration; + if (raw.baseGeneration !== null) { + if (raw.baseGeneration !== priorRawGeneration) { + throw new Error("rust HIR graph: stale producer base generation"); + } + } else if (priorRawGeneration !== undefined && raw.generation === priorRawGeneration) { + throw new Error("rust HIR graph: unchanged generation lost its base"); + } + + const nextRaw = + raw.baseGeneration === null + ? new Map() + : new Map(this.rawShards); + const touched = new Set(); + for (const key of raw.deletes) { + assertKey(key, "delete key"); + if (touched.has(key) || !nextRaw.delete(key)) { + throw new Error(`rust HIR graph: invalid duplicate/missing delete ${key}`); + } + touched.add(key); + } + for (const shard of raw.upserts) { + assertRawShard(shard, raw); + if (touched.has(shard.key)) { + throw new Error(`rust HIR graph: duplicate shard delta ${shard.key}`); + } + touched.add(shard.key); + nextRaw.set(shard.key, structuredClone(shard)); + } + const expectedRawManifest = [...nextRaw.values()] + .sort((left, right) => compareText(left.key, right.key)) + .map((shard) => ({ key: shard.key, digest: shard.digest })); + if (!sameManifest(raw.manifest, expectedRawManifest)) { + throw new Error("rust HIR graph: producer shard manifest mismatch"); + } + if ( + rawGeneration(raw.universe.digest, expectedRawManifest) !== raw.generation + ) { + throw new Error("rust HIR graph: producer generation digest mismatch"); + } + if ( + !this.restoringCheckpoint && + prior !== undefined && + raw.generation === priorRawGeneration && + raw.baseGeneration === priorRawGeneration && + raw.upserts.length === 0 && + raw.deletes.length === 0 + ) { + return { + changed: false, + mode: "unchanged", + snapshot: prior, + checkpoint: checkpointOf(raw, nextRaw), + }; + } + + const hello = helloOf(raw); + const nodeIds = nodeIdsOf(raw, nextRaw); + const nextGraph = + raw.baseGeneration === null || this.restoringCheckpoint + ? new Map() + : new Map(this.graphShards); + for (const key of raw.deletes) nextGraph.delete(graphKey(key)); + const graphUpserts = this.restoringCheckpoint + ? [...nextRaw.values()] + : raw.upserts; + for (const shard of graphUpserts) { + const adapted = adaptShard(this.root, raw, shard, nodeIds); + nextGraph.set(adapted.key, adapted); + } + const metadata = metadataShard(this.root, raw, nextRaw, nodeIds); + nextGraph.set(metadata.key, metadata); + + const sequence = (prior?.protocol?.sequence ?? 0) + 1; + const graphManifest = [...nextGraph] + .sort(([left], [right]) => compareText(left, right)) + .map(([key, shard]) => ({ + key, + digest: GraphSnapshotProtocol.shardDigest(shard), + })); + const begin = beginOf(raw, sequence, graphManifest, nextGraph, prior); + const commit = commitOf(hello, begin, graphManifest, nextGraph); + const frames: GraphSnapshotProtocol.Frame[] = [hello, begin]; + if (begin.baseGeneration === undefined) { + for (const entry of graphManifest) { + frames.push({ + type: "upsertShard", + digest: entry.digest, + shard: structuredClone(nextGraph.get(entry.key)!), + }); + } + } else { + const previous = new Map( + prior!.protocol!.shards.map((entry) => [entry.key, entry.digest]), + ); + for (const entry of graphManifest) { + if (previous.get(entry.key) === entry.digest) continue; + frames.push({ + type: "upsertShard", + digest: entry.digest, + shard: structuredClone(nextGraph.get(entry.key)!), + }); + } + for (const key of previous.keys()) { + if (!nextGraph.has(key)) frames.push({ type: "deleteShard", key }); + } + } + frames.push(commit); + + const fullBegin: GraphSnapshotProtocol.IBegin = { + ...begin, + sequence, + baseSequence: undefined, + baseGeneration: undefined, + }; + const fullFrames: GraphSnapshotProtocol.Frame[] = [hello, fullBegin]; + for (const entry of graphManifest) { + fullFrames.push({ + type: "upsertShard", + digest: entry.digest, + shard: structuredClone(nextGraph.get(entry.key)!), + }); + } + fullFrames.push({ ...commit, sequence }); + new GraphSnapshotProtocol.Store(this.root).apply(fullFrames); + + const checkpoint = checkpointOf(raw, nextRaw); + const state: IRustGraphCacheState = { + version: 1, + producerCommit: this.producerCommit, + checkpoint, + rawShards: [...nextRaw.values()].map((shard) => structuredClone(shard)), + frames: fullFrames, + }; + const mode: IBulkGraphSession.Mode = + prior === undefined + ? "initial" + : raw.baseGeneration === null + ? prior.provenance.universe === raw.universe.digest + ? "rebuild" + : "reload" + : "incremental"; + return { + changed: true, + mode, + frames, + state, + sequence, + generation: raw.generation, + commit: (snapshot) => { + this.rawShards = nextRaw; + this.graphShards = nextGraph; + this.rawGeneration = raw.generation; + this.checkpoint = checkpoint; + this.restoringCheckpoint = false; + return snapshot; + }, + }; + } + + private restore(cached: IRustGraphCacheState): void { + if ( + cached.version !== 1 || + cached.producerCommit !== this.producerCommit || + cached.checkpoint.producer.commit !== this.producerCommit + ) { + throw new Error("rust HIR graph: persisted producer identity mismatch"); + } + const rawShards = [...cached.rawShards].sort((left, right) => + compareText(left.key, right.key), + ); + for (const shard of rawShards) assertRawShardPayload(shard); + if ( + !isSortedUnique(rawShards.map((shard) => shard.key)) || + !rawShards.every((shard) => shard.key.endsWith(`\0${shard.source}`)) + ) { + throw new Error("rust HIR graph: persisted raw shard identity mismatch"); + } + const manifest = rawShards.map((shard) => ({ + key: shard.key, + digest: shard.digest, + })); + const sources = [...rawShards] + .sort((left, right) => compareText(left.source, right.source)) + .map((shard) => ({ + source: shard.source, + checkerDigest: shard.checkerDigest, + })); + if ( + !sameManifest(cached.checkpoint.manifest, manifest) || + canonical(cached.checkpoint.sources) !== canonical(sources) || + canonical(cached.checkpoint.shards) !== canonical(rawShards) || + rawGeneration(cached.checkpoint.universe, manifest) !== + cached.checkpoint.generation + ) { + throw new Error("rust HIR graph: persisted producer checkpoint is corrupt"); + } + const snapshot = new GraphSnapshotProtocol.Store(this.root).apply( + cached.frames, + ); + if ( + snapshot.protocol?.generation !== cached.checkpoint.generation || + snapshot.provenance.universe !== cached.checkpoint.universe || + snapshot.provenance.provider !== RUST_HIR_PROVIDER || + snapshot.provenance.tool !== cached.checkpoint.producer.name || + snapshot.provenance.toolVersion !== + `${cached.checkpoint.producer.version} (${cached.checkpoint.producer.commit})` + ) { + throw new Error("rust HIR graph: persisted checkpoint generation mismatch"); + } + this.rawShards = new Map( + cached.rawShards.map((shard) => [shard.key, structuredClone(shard)]), + ); + this.rawGeneration = cached.checkpoint.generation; + this.checkpoint = structuredClone(cached.checkpoint); + // Normalized frames are a local cache artifact, not producer evidence. + // Keep only the raw checkpoint until the restarted producer validates it; + // the next response then reconstructs every public shard from those raw + // HIR facts before anything becomes resident again. + this.restoringCheckpoint = true; + } +} + +export namespace RustGraphSnapshotAdapter { + export type IPrepared = + | { + changed: false; + mode: "unchanged"; + snapshot: IBulkGraphSession.ISnapshot; + checkpoint: IRustGraphCheckpoint; + } + | { + changed: true; + mode: IBulkGraphSession.Mode; + frames: GraphSnapshotProtocol.Frame[]; + state: IRustGraphCacheState; + sequence: number; + generation: string; + commit: ( + snapshot: IBulkGraphSession.ISnapshot, + ) => IBulkGraphSession.ISnapshot; + }; +} + +function adaptShard( + root: string, + raw: IRustGraphSnapshot, + shard: IRustGraphShard, + nodeIds: ReadonlyMap, +): GraphSnapshotProtocol.IShard { + const source = sourceFile(root, shard.source); + return { + key: graphKey(shard.key), + target: raw.universe.target, + languages: ["rust"], + nodes: shard.nodes + .filter((node) => !node.external) + .map((node) => adaptNode(root, raw, node)), + edges: shard.edges.map((edge) => ({ + from: requireNodeId(nodeIds, edge.from, "edge source"), + to: requireNodeId(nodeIds, edge.to, "edge target"), + kind: edge.kind as GraphEdgeKind, + ...(edge.evidence === null + ? {} + : { evidence: adaptEvidence(root, edge.evidence) }), + })), + diagnostics: shard.diagnostics.map((diagnostic) => ({ + file: graphFile(root, diagnostic.file), + line: diagnostic.line, + ...(diagnostic.column === null ? {} : { column: diagnostic.column }), + code: diagnostic.code, + message: diagnostic.message, + ...(diagnostic.severity === null + ? {} + : { + severity: + diagnostic.severity as ISamchonGraphDiagnostic["severity"], + }), + })), + coverage: [], + unresolved: shard.unresolved.map((site) => ({ + provider: RUST_HIR_PROVIDER, + language: "rust", + target: raw.universe.target, + universe: raw.universe.digest, + family: site.family as GraphEdgeKind, + evidence: adaptEvidence(root, site.evidence), + reason: site.reason as ISamchonGraphUnresolved["reason"], + ...(site.candidates.length === 0 + ? {} + : { + candidates: site.candidates.map( + (candidate) => nodeIds.get(candidate) ?? candidate, + ), + }), + })), + sources: [ + { + file: source, + checkerDigest: shard.checkerDigest, + diskDigest: source.startsWith("bundled:///") + ? "" + : shard.checkerDigest, + }, + ], + }; +} + +function metadataShard( + root: string, + raw: IRustGraphSnapshot, + shards: ReadonlyMap, + nodeIds: ReadonlyMap, +): GraphSnapshotProtocol.IShard { + const coverage = coverageOf(raw, shards); + const external = new Map(); + for (const shard of shards.values()) { + for (const node of shard.nodes.filter((node) => node.external)) { + const adapted = adaptNode(root, raw, node); + const prior = external.get(adapted.id); + if (prior !== undefined && canonical(prior) !== canonical(adapted)) { + throw new Error(`rust HIR graph: external node ${adapted.id} disagrees across shards`); + } + external.set(adapted.id, adapted); + } + } + const nodes = [...external.values()].sort((left, right) => + compareText(left.id, right.id), + ); + const dependencyDigest = digest(nodes); + return { + key: `rust-metadata:${raw.universe.digest}`, + target: raw.universe.target, + languages: ["rust"], + nodes, + edges: [], + diagnostics: [], + coverage, + unresolved: [], + sources: [ + { + file: "bundled:///rust/dependencies", + checkerDigest: dependencyDigest, + diskDigest: "", + }, + { + file: "bundled:///rust/universe", + checkerDigest: raw.universe.digest, + diskDigest: "", + }, + ], + }; +} + +function coverageOf( + raw: IRustGraphSnapshot, + shards: ReadonlyMap, +): ISamchonGraphCoverage[] { + let established: string | undefined; + let rows: IRustGraphCoverage[] | undefined; + for (const shard of shards.values()) { + const current = canonical( + [...shard.coverage].sort((left, right) => compareText(left.family, right.family)), + ); + if (established !== undefined && established !== current) { + throw new Error("rust HIR graph: shards disagree about coverage"); + } + established = current; + rows = shard.coverage; + } + if (rows === undefined) throw new Error("rust HIR graph: snapshot has no coverage"); + return [...rows] + .sort((left, right) => compareText(left.family, right.family)) + .map((row) => ({ + provider: RUST_HIR_PROVIDER, + language: "rust", + target: raw.universe.target, + family: row.family as GraphEdgeKind, + state: row.state as ISamchonGraphCoverage["state"], + })); +} + +function adaptNode( + root: string, + raw: IRustGraphSnapshot, + node: IRustGraphNode, +): ISamchonGraphNode { + const kind = node.external ? "external_symbol" : (node.kind as GraphNodeKind); + return { + id: rustGraphNodeId(raw, node), + kind, + language: "rust", + name: node.name, + ...(node.qualifiedName === null + ? {} + : { qualifiedName: node.qualifiedName }), + file: graphFile(root, node.file), + external: node.external, + ...(node.exported ? { exported: true } : {}), + ...(node.signature === null ? {} : { signature: node.signature }), + ...(node.evidence === null + ? {} + : { evidence: adaptEvidence(root, node.evidence) }), + }; +} + +function nodeIdsOf( + raw: IRustGraphSnapshot, + shards: ReadonlyMap, +): Map { + const output = new Map(); + for (const shard of shards.values()) { + for (const node of shard.nodes) { + const adapted = rustGraphNodeId(raw, node); + const prior = output.get(node.id); + if (prior !== undefined && prior !== adapted) { + throw new Error(`rust HIR graph: native node identity disagrees ${node.id}`); + } + output.set(node.id, adapted); + } + } + return output; +} + +function rustGraphNodeId(raw: IRustGraphSnapshot, node: IRustGraphNode): string { + const role = node.external ? "external_symbol" : (node.kind as GraphNodeKind); + const display = node.qualifiedName ?? node.name; + return semanticGraphNodeId( + { + version: 2, + language: "rust", + symbol: display, + role, + native: { key: node.id, stability: "semantic" }, + scope: { target: raw.universe.target }, + stability: "persistent", + }, + display, + ); +} + +function requireNodeId( + nodeIds: ReadonlyMap, + rawId: string, + label: string, +): string { + const id = nodeIds.get(rawId); + if (id === undefined) { + throw new Error(`rust HIR graph: ${label} is absent ${rawId}`); + } + return id; +} + +function adaptEvidence( + root: string, + evidence: IRustGraphEvidence, +): ISamchonGraphEvidence { + return { + file: graphFile(root, evidence.file), + startLine: evidence.startLine, + startCol: evidence.startColumn, + endLine: evidence.endLine, + endCol: evidence.endColumn, + }; +} + +function helloOf(raw: IRustGraphSnapshot): GraphSnapshotProtocol.IHello { + const compilerVersion = + raw.universe.configurations + .find((row) => row.startsWith("rustc-version=")) + ?.slice("rustc-version=".length) ?? "unavailable"; + return { + type: "hello", + protocolVersion: 1, + schemaVersion: 1, + producerSchemaVersion: raw.schemaVersion, + provider: RUST_HIR_PROVIDER, + producer: raw.producer.name, + producerVersion: `${raw.producer.version} (${raw.producer.commit})`, + compilerVersion, + languages: ["rust"], + authority: "analyzer", + supportedFacts: [...RUST_HIR_FACTS], + capabilities: [...CAPABILITIES], + }; +} + +function beginOf( + raw: IRustGraphSnapshot, + sequence: number, + _manifest: readonly IBulkGraphSession.IShard[], + shards: ReadonlyMap, + prior: IBulkGraphSession.ISnapshot | undefined, +): GraphSnapshotProtocol.IBegin { + const sources = [...shards.values()].flatMap((shard) => shard.sources); + const canDelta = raw.baseGeneration !== null && prior !== undefined; + return { + type: "begin", + sequence, + generation: raw.generation, + ...(canDelta + ? { + baseSequence: prior.protocol!.sequence, + baseGeneration: prior.protocol!.generation, + } + : {}), + universe: raw.universe.digest, + manifest: GraphSnapshotProtocol.manifestDigest(sources), + targets: [raw.universe.target], + }; +} + +function commitOf( + hello: GraphSnapshotProtocol.IHello, + begin: GraphSnapshotProtocol.IBegin, + manifest: IBulkGraphSession.IShard[], + shards: ReadonlyMap, +): GraphSnapshotProtocol.ICommit { + const facts = factsOf(hello, begin, manifest, shards); + return { + type: "commit", + sequence: begin.sequence, + generation: begin.generation, + shards: manifest, + factDigest: GraphSnapshotProtocol.factDigest(facts), + }; +} + +function factsOf( + hello: GraphSnapshotProtocol.IHello, + begin: GraphSnapshotProtocol.IBegin, + manifest: readonly IBulkGraphSession.IShard[], + shards: ReadonlyMap, +): Pick< + IBulkGraphSession.ISnapshot, + | "languages" + | "nodes" + | "edges" + | "diagnostics" + | "coverage" + | "unresolved" + | "provenance" +> { + const ordered = manifest.map((entry) => shards.get(entry.key)!); + return { + languages: ["rust"], + nodes: ordered.flatMap((shard) => shard.nodes), + edges: ordered.flatMap((shard) => shard.edges), + diagnostics: ordered.flatMap((shard) => shard.diagnostics), + coverage: ordered.flatMap((shard) => shard.coverage), + unresolved: ordered.flatMap((shard) => shard.unresolved), + provenance: { + provider: hello.provider, + authority: hello.authority, + facts: [...hello.supportedFacts], + schemaVersion: hello.producerSchemaVersion, + tool: hello.producer, + toolVersion: hello.producerVersion, + compilerVersion: hello.compilerVersion, + protocolVersion: hello.protocolVersion, + universe: begin.universe, + capabilities: [...hello.capabilities], + }, + }; +} + +function checkpointOf( + raw: IRustGraphSnapshot, + shards: ReadonlyMap, +): IRustGraphCheckpoint { + return { + protocolVersion: raw.protocolVersion, + schemaVersion: raw.schemaVersion, + producer: structuredClone(raw.producer), + universe: raw.universe.digest, + generation: raw.generation, + manifest: raw.manifest.map((entry) => ({ ...entry })), + sources: [...shards.values()] + .sort((left, right) => compareText(left.source, right.source)) + .map((shard) => ({ + source: shard.source, + checkerDigest: shard.checkerDigest, + })), + shards: [...shards.values()] + .sort((left, right) => compareText(left.key, right.key)) + .map((shard) => structuredClone(shard)), + }; +} + +function assertSnapshot(raw: IRustGraphSnapshot, commit: string): void { + if (raw === null || typeof raw !== "object") { + throw new Error("rust HIR graph: response is not an object"); + } + if (raw.protocolVersion !== 1 || raw.schemaVersion !== 1) { + throw new Error("rust HIR graph: unsupported producer protocol/schema"); + } + if ( + raw.producer?.name !== RUST_HIR_PRODUCER || + raw.producer.commit !== commit || + typeof raw.producer.version !== "string" || + raw.producer.version === "" + ) { + throw new Error("rust HIR graph: producer identity/commit mismatch"); + } + assertDigest(raw.universe?.digest, "universe digest"); + assertString(raw.universe?.target, "universe target"); + assertStringArray(raw.universe?.workspaceRoots, "workspace roots"); + assertStringArray(raw.universe?.toolchains, "toolchains"); + assertStringArray(raw.universe?.configurations, "configurations"); + assertDigest(raw.generation, "generation"); + if ( + !Number.isSafeInteger(raw.sequence) || + raw.sequence < 1 || + !Array.isArray(raw.upserts) || + !Array.isArray(raw.deletes) || + !Array.isArray(raw.manifest) || + raw.phases === null || + typeof raw.phases !== "object" + ) { + throw new Error("rust HIR graph: malformed generation envelope"); + } + if ( + ![ + raw.phases.semanticMillis, + raw.phases.shardMillis, + raw.phases.encodeMillis, + raw.phases.totalMillis, + ].every((value) => Number.isSafeInteger(value) && value >= 0) || + typeof raw.phases.cacheHit !== "boolean" + ) { + throw new Error("rust HIR graph: malformed phase telemetry"); + } + if (raw.baseGeneration !== null) assertDigest(raw.baseGeneration, "base generation"); + for (const entry of raw.manifest) { + assertKey(entry.key, "manifest key"); + assertDigest(entry.digest, "manifest digest"); + } + if (!isSortedUnique(raw.manifest.map((entry) => entry.key))) { + throw new Error("rust HIR graph: manifest is not sorted and unique"); + } + if (!isSortedUnique(raw.deletes)) { + throw new Error("rust HIR graph: deletes are not sorted and unique"); + } +} + +function assertRawShard(shard: IRustGraphShard, raw: IRustGraphSnapshot): void { + assertRawShardPayload(shard); + if (shard.key !== `${raw.universe.target}\0${shard.source}`) { + throw new Error("rust HIR graph: shard key does not match its universe/source"); + } +} + +function assertRawShardPayload(shard: IRustGraphShard): void { + assertKey(shard.key, "shard key"); + assertString(shard.source, "shard source"); + assertDigest(shard.checkerDigest, "checker digest"); + assertDigest(shard.interfaceFingerprint, "interface fingerprint"); + assertDigest(shard.digest, "shard digest"); + if (rawShardDigest(shard) !== shard.digest) { + throw new Error(`rust HIR graph: shard digest mismatch ${shard.key}`); + } + if ( + !Array.isArray(shard.nodes) || + !Array.isArray(shard.edges) || + !Array.isArray(shard.diagnostics) || + !Array.isArray(shard.coverage) || + !Array.isArray(shard.unresolved) + ) { + throw new Error("rust HIR graph: malformed shard arrays"); + } + for (const node of shard.nodes) { + assertNativeNodeId(node.id, "node id"); + assertString(node.name, "node name"); + assertString(node.file, "node file"); + if ( + typeof node.external !== "boolean" || + typeof node.exported !== "boolean" + ) { + throw new Error("rust HIR graph: malformed node flags"); + } + assertNullableString(node.qualifiedName, "qualified node name"); + assertNullableString(node.signature, "node signature"); + if (!NODE_KINDS.has(node.kind as GraphNodeKind)) { + throw new Error(`rust HIR graph: unknown node kind ${node.kind}`); + } + if (node.evidence !== null) assertEvidence(node.evidence); + } + for (const edge of shard.edges) { + assertNativeNodeId(edge.from, "edge from"); + assertNativeNodeId(edge.to, "edge to"); + if (!GRAPH_EDGE_KINDS.includes(edge.kind as GraphEdgeKind) || edge.kind === "renders") { + throw new Error(`rust HIR graph: unknown/unsupported edge kind ${edge.kind}`); + } + if (edge.evidence !== null) assertEvidence(edge.evidence); + } + for (const diagnostic of shard.diagnostics) { + assertString(diagnostic.file, "diagnostic file"); + assertPositiveInteger(diagnostic.line, "diagnostic line"); + if (diagnostic.column !== null) { + assertPositiveInteger(diagnostic.column, "diagnostic column"); + } + assertString(diagnostic.code, "diagnostic code"); + assertString(diagnostic.message, "diagnostic message"); + if ( + diagnostic.severity !== null && + !DIAGNOSTIC_SEVERITIES.has(diagnostic.severity) + ) { + throw new Error("rust HIR graph: invalid diagnostic severity"); + } + } + const coverage = new Map(); + for (const row of shard.coverage) { + if ( + !GRAPH_EDGE_KINDS.includes(row.family as GraphEdgeKind) || + !COVERAGE_STATES.has(row.state) || + coverage.has(row.family) + ) { + throw new Error("rust HIR graph: malformed coverage row"); + } + coverage.set(row.family, row.state); + } + if (coverage.size !== GRAPH_EDGE_KINDS.length) { + throw new Error("rust HIR graph: incomplete coverage matrix"); + } + for (const site of shard.unresolved) { + if ( + !GRAPH_EDGE_KINDS.includes(site.family as GraphEdgeKind) || + !UNRESOLVED_REASONS.has(site.reason as ISamchonGraphUnresolved["reason"]) || + !Array.isArray(site.candidates) || + site.candidates.some( + (candidate) => + typeof candidate !== "string" || + !candidate.startsWith("rust-hir-v1|"), + ) || + new Set(site.candidates).size !== site.candidates.length + ) { + throw new Error("rust HIR graph: malformed unresolved site"); + } + assertEvidence(site.evidence); + } +} + +function assertEvidence(evidence: IRustGraphEvidence): void { + assertString(evidence.file, "evidence file"); + for (const value of [ + evidence.startLine, + evidence.startColumn, + evidence.endLine, + evidence.endColumn, + ]) { + assertPositiveInteger(value, "evidence coordinate"); + } + if ( + evidence.endLine < evidence.startLine || + (evidence.endLine === evidence.startLine && + evidence.endColumn < evidence.startColumn) + ) { + throw new Error("rust HIR graph: reversed evidence range"); + } +} + +function rawShardDigest(shard: IRustGraphShard): string { + return digest({ + key: shard.key, + source: shard.source, + checkerDigest: shard.checkerDigest, + interfaceFingerprint: shard.interfaceFingerprint, + nodes: shard.nodes, + edges: shard.edges, + diagnostics: shard.diagnostics, + coverage: shard.coverage, + unresolved: shard.unresolved, + }); +} + +function rawGeneration( + universe: string, + manifest: readonly { key: string; digest: string }[], +): string { + return digest({ universe, manifest }); +} + +function graphFile(root: string, file: string): string { + if (file.startsWith("bundled:///")) return file; + return path.relative(root, path.resolve(root, file)).split(path.sep).join("/"); +} + +function sourceFile(root: string, file: string): string { + return file.startsWith("bundled:///") + ? file + : path.normalize(path.resolve(root, file)); +} + +function graphKey(rawKey: string): string { + return `rust-shard:${digest(rawKey)}`; +} + +function sameManifest( + left: readonly { key: string; digest: string }[], + right: readonly { key: string; digest: string }[], +): boolean { + return ( + left.length === right.length && + left.every( + (entry, index) => + entry.key === right[index]?.key && entry.digest === right[index]?.digest, + ) + ); +} + +function isSortedUnique(values: readonly string[]): boolean { + return values.every( + (value, index) => index === 0 || compareText(values[index - 1]!, value) < 0, + ); +} + +function assertString(value: unknown, label: string): asserts value is string { + if (typeof value !== "string" || value === "" || value.includes("\0")) { + throw new Error(`rust HIR graph: invalid ${label}`); + } +} + +function assertNativeNodeId(value: unknown, label: string): asserts value is string { + if ( + typeof value !== "string" || + value === "" || + value.includes("\0") || + (!value.startsWith("rust-hir-v1|") && + !value.startsWith("rust-file-v1|") && + !value.startsWith("rust-export-v1|")) + ) { + throw new Error( + `rust HIR graph: invalid ${label}: ${JSON.stringify(value)}`, + ); + } +} + +function assertNullableString( + value: unknown, + label: string, +): asserts value is string | null { + if (value !== null) assertString(value, label); +} + +function assertPositiveInteger(value: unknown, label: string): asserts value is number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + throw new Error(`rust HIR graph: invalid ${label}`); + } +} + +function assertKey(value: unknown, label: string): asserts value is string { + if (typeof value !== "string" || value === "") { + throw new Error(`rust HIR graph: invalid ${label}`); + } +} + +function assertDigest(value: unknown, label: string): asserts value is string { + if (typeof value !== "string" || !DIGEST.test(value)) { + throw new Error(`rust HIR graph: invalid ${label}`); + } +} + +function assertStringArray(value: unknown, label: string): asserts value is string[] { + if ( + !Array.isArray(value) || + value.some((entry) => typeof entry !== "string" || entry === "") || + new Set(value).size !== value.length + ) { + throw new Error(`rust HIR graph: invalid ${label}`); + } +} + +function digest(value: unknown): string { + return createHash("sha256").update(canonical(value)).digest("hex"); +} + +function canonical(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value.map((entry) => canonical(entry)).join(",")}]`; + } + const object = value as Record; + return `{${Object.keys(object) + .sort(compareText) + .map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`) + .join(",")}}`; +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/graph/src/provider/rust/index.ts b/packages/graph/src/provider/rust/index.ts index 72139cbe..695b082b 100644 --- a/packages/graph/src/provider/rust/index.ts +++ b/packages/graph/src/provider/rust/index.ts @@ -1 +1,24 @@ +export * from "./IRustGraphCacheState"; +export * from "./IRustGraphCheckpoint"; +export * from "./IRustGraphCheckpointSource"; +export * from "./IRustGraphCoverage"; +export * from "./IRustGraphDiagnostic"; +export * from "./IRustGraphEdge"; +export * from "./IRustGraphEvidence"; +export * from "./IRustGraphManifestEntry"; +export * from "./IRustGraphNode"; +export * from "./IRustGraphPhases"; +export * from "./IRustGraphProducer"; +export * from "./IRustGraphShard"; +export * from "./IRustGraphSnapshot"; +export * from "./IRustGraphSnapshotParams"; +export * from "./IRustGraphUniverse"; +export * from "./RUST_GRAPH_PRODUCER_COMMIT"; +export * from "./RUST_HIR_FACTS"; +export * from "./RUST_HIR_PRODUCER"; +export * from "./RUST_HIR_PROVIDER"; +export * from "./RustGraphCache"; +export * from "./RustGraphClient"; +export * from "./RustGraphSnapshotAdapter"; +export * from "./rustGraphProvider"; export * from "./rustScipProvider"; diff --git a/packages/graph/src/provider/rust/rustGraphProvider.ts b/packages/graph/src/provider/rust/rustGraphProvider.ts new file mode 100644 index 00000000..90e43f2c --- /dev/null +++ b/packages/graph/src/provider/rust/rustGraphProvider.ts @@ -0,0 +1,106 @@ +import { spawnSync } from "node:child_process"; + +import { assertGraphSnapshotContract } from "../assertGraphSnapshotContract"; +import { IGraphProvider } from "../IGraphProvider"; +import { resolveProviderCommand } from "../resolveProviderCommand"; +import { spawnableCommand } from "../../utils/spawnableCommand"; +import { RustGraphClient } from "./RustGraphClient"; +import { RUST_HIR_FACTS } from "./RUST_HIR_FACTS"; +import { RUST_GRAPH_PRODUCER_COMMIT } from "./RUST_GRAPH_PRODUCER_COMMIT"; +import { RUST_HIR_PROVIDER } from "./RUST_HIR_PROVIDER"; +import { rustScipProvider } from "./rustScipProvider"; + +const OVERRIDE = "SAMCHON_GRAPH_RUST_ANALYZER_HIR"; + +export const rustGraphProvider: IGraphProvider = { + name: RUST_HIR_PROVIDER, + languages: ["rust"], + authority: "analyzer", + facts: RUST_HIR_FACTS, + resolution: { + commands: ["samchon-rust-analyzer", "rust-analyzer"], + environmentOverrides: [OVERRIDE], + }, + fallbacks: [rustScipProvider], + buildInputs: rustScipProvider.buildInputs, + configuration: (_root, env) => [ + `producer-commit=${RUST_GRAPH_PRODUCER_COMMIT}`, + `${OVERRIDE}=${env[OVERRIDE] ?? "unconfigured"}`, + ], + refuse: (options) => { + const refused = [ + options.server === undefined ? undefined : "server", + options.maxFiles === undefined ? undefined : "maxFiles", + options.lspReferenceLimit === undefined + ? undefined + : "lspReferenceLimit", + ].filter((value): value is string => value !== undefined); + return refused.length === 0 + ? undefined + : `rust: ${RUST_HIR_PROVIDER} publishes whole-program generations and cannot honor ${refused.join(", ")}`; + }, + resolve: (root, env) => resolvePinned(root, env), + open: (props) => + new RustGraphClient({ + root: props.root, + command: props.command.command, + args: props.command.args, + producerCommit: RUST_GRAPH_PRODUCER_COMMIT, + initializationOptions: props.options.initializationOptions, + requestTimeoutMs: props.options.lspTimeoutMs, + readyTimeoutMs: props.options.lspReadyTimeoutMs, + maxMessageBytes: props.options.lspMaxMessageBytes, + windowsVerbatimArguments: props.command.windowsVerbatimArguments, + validate: (snapshot) => + assertGraphSnapshotContract( + snapshot, + rustGraphProvider, + props.languages, + props.root, + ), + }), +}; + +function resolvePinned( + root: string, + env: NodeJS.ProcessEnv, +): IGraphProvider.ICommand | undefined { + for (const command of ["samchon-rust-analyzer", "rust-analyzer"]) { + const candidate = resolveProviderCommand(root, env, { + command, + override: OVERRIDE, + }); + if (candidate !== undefined && hasPinnedVersion(root, env, candidate)) { + return candidate; + } + } + return undefined; +} + +function hasPinnedVersion( + root: string, + env: NodeJS.ProcessEnv, + command: IGraphProvider.ICommand, +): boolean { + const invocation = spawnableCommand.append( + { ...command, args: [...command.args] }, + ["--version"], + ); + const result = spawnSync(invocation.command, invocation.args, { + cwd: root, + encoding: "utf8", + env, + shell: false, + timeout: 10_000, + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }); + if (result.status !== 0 || result.error !== undefined) return false; + const reported = /\(([0-9a-f]{7,40})(?:\s|\))/u.exec( + result.stdout, + )?.[1]; + return ( + reported !== undefined && + RUST_GRAPH_PRODUCER_COMMIT.startsWith(reported) + ); +} diff --git a/packages/graph/src/provider/rust/rustScipProvider.ts b/packages/graph/src/provider/rust/rustScipProvider.ts index f336a6b3..425f606c 100644 --- a/packages/graph/src/provider/rust/rustScipProvider.ts +++ b/packages/graph/src/provider/rust/rustScipProvider.ts @@ -12,6 +12,33 @@ import { resolveProviderCommand } from "../resolveProviderCommand"; import { toolchainVersion } from "../toolchainVersion"; import { scipProvider } from "../scip"; +const RUST_GRAPH_TOOLS = Object.freeze({ + analyzer: Object.freeze({ + command: "rust-analyzer", + override: "SAMCHON_GRAPH_RUST_ANALYZER", + }), + decoder: Object.freeze({ + command: "scip", + override: "SAMCHON_GRAPH_SCIP", + }), + compiler: Object.freeze({ + command: "rustc", + override: "SAMCHON_GRAPH_RUSTC", + }), + cargo: Object.freeze({ + command: "cargo", + override: "SAMCHON_GRAPH_CARGO", + }), +}); +const RUST_GRAPH_RESOLUTION = Object.freeze({ + commands: Object.freeze( + Object.values(RUST_GRAPH_TOOLS).map((tool) => tool.command), + ), + environmentOverrides: Object.freeze( + Object.values(RUST_GRAPH_TOOLS).map((tool) => tool.override), + ), +}) satisfies IGraphProvider.IResolution; + /** * rust-analyzer's stock SCIP export is a navigation artifact, not HIR facts. * @@ -34,6 +61,9 @@ export const rustScipProvider = Object.assign( // rust-analyzer writes the protobuf default empty string for every // document, not a copy of the source bytes it analyzed. sourceText: false, + // Stock rust-analyzer SCIP emits occurrences, definitions, and references, + // but no relationship that proves the referenced symbol is a type. + omitFacts: ["type_ref"], // Stock rust-analyzer omits the protobuf-default project_root. The session // invokes `rust-analyzer scip .` with the project root as its exact cwd and // an isolated output artifact, so that cwd is the missing root evidence; an @@ -42,6 +72,7 @@ export const rustScipProvider = Object.assign( languageOf, }), { + resolution: RUST_GRAPH_RESOLUTION, indexArgs: rustScipIndexArgs, inputs: rustInputs, decodeCommand: rustScipDecoder, @@ -57,12 +88,27 @@ function resolveRustScipCommand( const analyzer = resolveTool( root, env, - "rust-analyzer", - "SAMCHON_GRAPH_RUST_ANALYZER", + RUST_GRAPH_TOOLS.analyzer.command, + RUST_GRAPH_TOOLS.analyzer.override, + ); + const decoder = resolveTool( + root, + env, + RUST_GRAPH_TOOLS.decoder.command, + RUST_GRAPH_TOOLS.decoder.override, + ); + const rustc = resolveTool( + root, + env, + RUST_GRAPH_TOOLS.compiler.command, + RUST_GRAPH_TOOLS.compiler.override, + ); + const cargo = resolveTool( + root, + env, + RUST_GRAPH_TOOLS.cargo.command, + RUST_GRAPH_TOOLS.cargo.override, ); - const decoder = resolveTool(root, env, "scip", "SAMCHON_GRAPH_SCIP"); - const rustc = resolveTool(root, env, "rustc", "SAMCHON_GRAPH_RUSTC"); - const cargo = resolveTool(root, env, "cargo", "SAMCHON_GRAPH_CARGO"); if ( analyzer === undefined || decoder === undefined || @@ -81,7 +127,12 @@ function rustScipDecoder( root: string, env: NodeJS.ProcessEnv = process.env, ): IGraphProvider.ICommand { - const decoder = resolveTool(root, env, "scip", "SAMCHON_GRAPH_SCIP"); + const decoder = resolveTool( + root, + env, + RUST_GRAPH_TOOLS.decoder.command, + RUST_GRAPH_TOOLS.decoder.override, + ); if (decoder === undefined) { throw new Error( "rust-analyzer-scip: the SCIP decoder disappeared after provider selection", @@ -177,19 +228,31 @@ function rustScipConfigurationDerivation( toolObservation( root, env, - "rust-analyzer", - "SAMCHON_GRAPH_RUST_ANALYZER", + RUST_GRAPH_TOOLS.analyzer.command, + RUST_GRAPH_TOOLS.analyzer.override, ["--version"], ), toolObservation( root, env, - "scip", - "SAMCHON_GRAPH_SCIP", + RUST_GRAPH_TOOLS.decoder.command, + RUST_GRAPH_TOOLS.decoder.override, ["--version"], ), - toolObservation(root, env, "rustc", "SAMCHON_GRAPH_RUSTC", ["-vV"]), - toolObservation(root, env, "cargo", "SAMCHON_GRAPH_CARGO", ["-V"]), + toolObservation( + root, + env, + RUST_GRAPH_TOOLS.compiler.command, + RUST_GRAPH_TOOLS.compiler.override, + ["-vV"], + ), + toolObservation( + root, + env, + RUST_GRAPH_TOOLS.cargo.command, + RUST_GRAPH_TOOLS.cargo.override, + ["-V"], + ), ]); } @@ -247,7 +310,10 @@ function rustCompilerVersion( _languages: readonly GraphLanguage[] | undefined, configuration: readonly string[], ): string { - const wanted = new Set(["rustc", "cargo"]); + const wanted = new Set([ + RUST_GRAPH_TOOLS.compiler.command, + RUST_GRAPH_TOOLS.cargo.command, + ]); return configuration .filter((row) => wanted.has(row.slice(0, Math.max(0, row.indexOf("="))))) .join("; "); @@ -261,11 +327,17 @@ function rustCompilerVersionFor( toolVersion( root, env, - "rustc", - "SAMCHON_GRAPH_RUSTC", + RUST_GRAPH_TOOLS.compiler.command, + RUST_GRAPH_TOOLS.compiler.override, ["-vV"], ), - toolVersion(root, env, "cargo", "SAMCHON_GRAPH_CARGO", ["-V"]), + toolVersion( + root, + env, + RUST_GRAPH_TOOLS.cargo.command, + RUST_GRAPH_TOOLS.cargo.override, + ["-V"], + ), ].join("; "); } @@ -335,9 +407,9 @@ const RUST_ENVIRONMENT_KEYS: readonly string[] = [ "RUSTFLAGS", "RUSTUP_HOME", "RUSTUP_TOOLCHAIN", - "SAMCHON_GRAPH_CARGO", - "SAMCHON_GRAPH_RUST_ANALYZER", - "SAMCHON_GRAPH_RUSTC", - "SAMCHON_GRAPH_SCIP", + RUST_GRAPH_TOOLS.cargo.override, + RUST_GRAPH_TOOLS.analyzer.override, + RUST_GRAPH_TOOLS.compiler.override, + RUST_GRAPH_TOOLS.decoder.override, ]; const RUST_ENVIRONMENT_KEY_SET = new Set(RUST_ENVIRONMENT_KEYS); diff --git a/packages/graph/src/provider/scip/standardScipProviders.ts b/packages/graph/src/provider/scip/standardScipProviders.ts index f0d45926..8b40baa1 100644 --- a/packages/graph/src/provider/scip/standardScipProviders.ts +++ b/packages/graph/src/provider/scip/standardScipProviders.ts @@ -14,6 +14,15 @@ import { resolveProviderCommand } from "../resolveProviderCommand"; import { toolchainVersion } from "../toolchainVersion"; import { scipProvider } from "./scipProvider"; +const SCIP_DECODER = Object.freeze({ + command: "scip", + override: "SAMCHON_GRAPH_SCIP", +}); +const COMPILATION_DATABASE_INPUTS: readonly string[] = Object.freeze([ + "compile_commands.json", + "build/compile_commands.json", +]); + const clangScipProvider = createScipProvider({ name: "scip-clang", // scip-clang 0.4.0 writes occurrence range/symbol/roles only. Its @@ -26,7 +35,11 @@ const clangScipProvider = createScipProvider({ // even though its compilation database was exactly what the indexer consumes. // What the index means is decided by the driver each translation unit was // actually compiled with, and the database records that per entry. - toolchain: { label: "cc", fromProject: compilationDatabaseCompilers }, + toolchain: { + label: "cc", + fromProject: compilationDatabaseCompilers, + sources: COMPILATION_DATABASE_INPUTS, + }, languages: ["c", "cpp"], command: "scip-clang", override: "SAMCHON_GRAPH_SCIP_CLANG", @@ -468,6 +481,7 @@ type IToolchain = env: NodeJS.ProcessEnv, ) => readonly string[]; override?: never; + sources: readonly string[]; /** What the rows call this toolchain when the project names none. */ label: string; @@ -492,115 +506,146 @@ function createScipProvider( ): IGraphProvider { const validateConfiguration = props.validateConfiguration; const producerConfiguration = props.producerConfiguration; - return scipProvider({ - name: props.name, - languages: props.languages, - authority: "semantic-index", - omitFacts: props.omitFacts, - ...(props.preferFileLanguage === undefined - ? {} - : { preferFileLanguage: props.preferFileLanguage }), - buildInputs: (root) => - withDerived( - providerInputFiles(root, [], props.buildFiles, props.buildExtensions), - props.derivedInputs?.(root), - ), - resolve: (root, env) => { - const indexer = resolveProviderCommand(root, env, { - command: props.command, - override: props.override, - }); - const decoder = resolveScipDecoder(root, env); - // The toolchain is required, not merely reported. A snapshot states which - // language version resolved its facts, and a provider that cannot answer - // that would publish `unavailable` into the field a consumer degrades - // against — which is worse than declining, because a fallback at least - // says so. `rust-analyzer-scip` refuses without `rustc` and `cargo` for - // the same reason. - // - // What has to resolve is the toolchain the project actually uses, not one - // chosen name for it. Requiring `clang` declined every GCC or MSVC project - // whose compilation database scip-clang would have consumed, and requiring - // `python3` declined a Windows interpreter installed as `python`. - const toolchain = resolveToolchain(root, env, props.toolchain); - const resolvedArgs = props.resolveArgs?.(root); - if ( - indexer === undefined || - decoder === undefined || - toolchain.some((tool) => tool.resolved === undefined) || - (props.resolveArgs !== undefined && resolvedArgs === undefined) - ) { - return undefined; - } - const args = resolvedArgs ?? []; - return spawnableCommand.append( - { ...indexer, args: [...indexer.args] }, - args, - ); - }, - decode: (root) => { - const decoder = resolveScipDecoder(root, process.env); - if (decoder === undefined) { - throw new Error( - `${props.name}: the SCIP decoder disappeared after provider selection`, - ); - } - return spawnableCommand.append( - { ...decoder, args: [...decoder.args] }, - ["print", "--json"], - ); - }, - indexArgs: props.indexArgs, - ...(props.artifactFrom === undefined - ? {} - : { artifactFrom: props.artifactFrom }), - inputs: (root, languages) => - withDerived( - providerInputFiles( - root, - languages, - props.buildFiles, - props.buildExtensions, - ), - props.derivedInputs?.(root), - ), - ...(validateConfiguration === undefined + const resolution = Object.freeze({ + commands: Object.freeze([ + props.command, + SCIP_DECODER.command, + ...(props.toolchain.aliases ?? []), + ]), + environmentOverrides: Object.freeze([ + props.override, + SCIP_DECODER.override, + ...(props.toolchain.override === undefined + ? [] + : [props.toolchain.override]), + ]), + ...(props.toolchain.fromProject === undefined ? {} : { - validateConfiguration: ( - root, - _languages, - configuration, - ) => validateConfiguration(root, configuration), + projectCommandSources: Object.freeze([ + ...props.toolchain.sources, + ]), }), - configuration: (root, _languages, env = process.env) => { - const producerRow = - producerConfiguration === undefined - ? toolVersion(root, env, props.command, props.override) - : producerConfiguration( + }) satisfies IGraphProvider.IResolution; + return Object.assign( + scipProvider({ + name: props.name, + languages: props.languages, + authority: "semantic-index", + omitFacts: props.omitFacts, + ...(props.preferFileLanguage === undefined + ? {} + : { preferFileLanguage: props.preferFileLanguage }), + buildInputs: (root) => + withDerived( + providerInputFiles(root, [], props.buildFiles, props.buildExtensions), + props.derivedInputs?.(root), + ), + resolve: (root, env) => { + const indexer = resolveProviderCommand(root, env, { + command: props.command, + override: props.override, + }); + const decoder = resolveScipDecoder(root, env); + // The toolchain is required, not merely reported. A snapshot states which + // language version resolved its facts, and a provider that cannot answer + // that would publish `unavailable` into the field a consumer degrades + // against — which is worse than declining, because a fallback at least + // says so. `rust-analyzer-scip` refuses without `rustc` and `cargo` for + // the same reason. + // + // What has to resolve is the toolchain the project actually uses, not one + // chosen name for it. Requiring `clang` declined every GCC or MSVC project + // whose compilation database scip-clang would have consumed, and requiring + // `python3` declined a Windows interpreter installed as `python`. + const toolchain = resolveToolchain(root, env, props.toolchain); + const resolvedArgs = props.resolveArgs?.(root); + if ( + indexer === undefined || + decoder === undefined || + toolchain.some((tool) => tool.resolved === undefined) || + (props.resolveArgs !== undefined && resolvedArgs === undefined) + ) { + return undefined; + } + const args = resolvedArgs ?? []; + return spawnableCommand.append( + { ...indexer, args: [...indexer.args] }, + args, + ); + }, + decode: (root) => { + const decoder = resolveScipDecoder(root, process.env); + if (decoder === undefined) { + throw new Error( + `${props.name}: the SCIP decoder disappeared after provider selection`, + ); + } + return spawnableCommand.append( + { ...decoder, args: [...decoder.args] }, + ["print", "--json"], + ); + }, + indexArgs: props.indexArgs, + ...(props.artifactFrom === undefined + ? {} + : { artifactFrom: props.artifactFrom }), + inputs: (root, languages) => + withDerived( + providerInputFiles( + root, + languages, + props.buildFiles, + props.buildExtensions, + ), + props.derivedInputs?.(root), + ), + ...(validateConfiguration === undefined + ? {} + : { + validateConfiguration: ( root, - env, - resolveProviderCommand.attempt(root, env, { - command: props.command, - override: props.override, - }), - ); - return toolchainVersion.derive([ - producerRow, - toolVersion(root, env, "scip", "SAMCHON_GRAPH_SCIP"), - ...toolchainVersions(root, env, props.toolchain), - ]); + _languages, + configuration, + ) => validateConfiguration(root, configuration), + }), + configuration: (root, _languages, env = process.env) => { + const producerRow = + producerConfiguration === undefined + ? toolVersion(root, env, props.command, props.override) + : producerConfiguration( + root, + env, + resolveProviderCommand.attempt(root, env, { + command: props.command, + override: props.override, + }), + ); + return toolchainVersion.derive([ + producerRow, + toolVersion( + root, + env, + SCIP_DECODER.command, + SCIP_DECODER.override, + ), + ...toolchainVersions(root, env, props.toolchain), + ]); + }, + // Selected from the configuration rather than re-derived, so the + // published compiler is the one this universe was computed from. Labelled + // rather than positional: the indexer and the decoder are named exactly, + // and whatever remains is the toolchain. + compilerVersion: (_root, selectedLanguages, configuration) => + props.compilerVersion?.(selectedLanguages, configuration) ?? + standardCompilerVersion(props.command, configuration), + sourceText: true, + languageOf, + }), + { + resolution, }, - // Selected from the configuration rather than re-derived, so the - // published compiler is the one this universe was computed from. Labelled - // rather than positional: the indexer and the decoder are named exactly, - // and whatever remains is the toolchain. - compilerVersion: (_root, selectedLanguages, configuration) => - props.compilerVersion?.(selectedLanguages, configuration) ?? - standardCompilerVersion(props.command, configuration), - sourceText: true, - languageOf, - }); + ); } /** @@ -1683,11 +1728,6 @@ const WINDOWS_EXECUTABLE_SUFFIX = /\.(?:com|exe|cmd|bat)$/i; /** A separator no path can contain. */ const SEPARATOR = String.fromCharCode(0); -const COMPILATION_DATABASE_INPUTS: readonly string[] = [ - "compile_commands.json", - "build/compile_commands.json", -]; - const compilationDatabases = new BoundedMap(64); @@ -1703,8 +1743,8 @@ function resolveScipDecoder( env: NodeJS.ProcessEnv, ): IGraphProvider.ICommand | undefined { return resolveProviderCommand(root, env, { - command: "scip", - override: "SAMCHON_GRAPH_SCIP", + command: SCIP_DECODER.command, + override: SCIP_DECODER.override, }); } diff --git a/packages/graph/src/provider/selectGraphProviders.ts b/packages/graph/src/provider/selectGraphProviders.ts index 4cd8d791..1c0e255e 100644 --- a/packages/graph/src/provider/selectGraphProviders.ts +++ b/packages/graph/src/provider/selectGraphProviders.ts @@ -44,33 +44,36 @@ export function selectGraphProviders( requested.has(language), ); if (owned.length === 0) continue; + const routes: selectGraphProviders.IRouteCandidate[] = []; + for (const route of [provider, ...(provider.fallbacks ?? [])]) { + const refusal = route.refuse(options); + if (refusal !== undefined) { + warnings.push(refusal); + continue; + } - const refusal = provider.refuse(options); - if (refusal !== undefined) { - warnings.push(refusal); - continue; - } - - const command = provider.resolve(root, env); - if (command === undefined) { - warnings.push( - `${owned.join(", ")}: the ${provider.name} ${provider.authority} provider was not found for this project; falling back to the generic language-server lane.`, - ); - continue; - } - - if (prepare && provider.prepare !== undefined) { - try { - provider.prepare(root, options); - } catch (error) { + const command = route.resolve(root, env); + if (command === undefined) { warnings.push( - `${owned.join(", ")}: the ${provider.name} ${provider.authority} provider could not prepare this project, so it cannot answer for it: ${(error as Error).message}`, + `${owned.join(", ")}: the ${route.name} ${route.authority} provider was not found for this project; trying the next strict route if one is available.`, ); continue; } - } - candidates.push({ provider, languages: owned, command }); + if (prepare && route.prepare !== undefined) { + try { + route.prepare(root, options); + } catch (error) { + warnings.push( + `${owned.join(", ")}: the ${route.name} ${route.authority} provider could not prepare this project, so it cannot answer for it: ${(error as Error).message}`, + ); + continue; + } + } + routes.push({ provider: route, languages: owned, command }); + } + const [selected, ...fallbacks] = routes; + if (selected !== undefined) candidates.push({ ...selected, fallbacks }); } return { candidates, warnings }; @@ -91,8 +94,13 @@ export namespace selectGraphProviders { languages: GraphLanguage[]; command: IGraphProvider.ICommand; + + /** Already-resolved strict routes attempted if this route fails. */ + fallbacks: IRouteCandidate[]; } + export type IRouteCandidate = Omit; + export interface IResult { /** Providers that can serve this build, in registry order. */ candidates: ICandidate[]; @@ -127,21 +135,35 @@ function assertOneOwnerPerLanguage( const owners = new Map(); const names = new Set(); for (const provider of registry) { - if (names.has(provider.name)) { - throw new Error( - `@samchon/graph: provider "${provider.name}" is registered more than once; provenance needs one stable provider identity`, - ); - } - names.add(provider.name); - if (provider.languages.length === 0) { - throw new Error( - `@samchon/graph: provider "${provider.name}" owns no language, so nothing can select it`, - ); - } - if (new Set(provider.facts).size !== provider.facts.length) { - throw new Error( - `@samchon/graph: provider "${provider.name}" declares one fact family more than once`, - ); + for (const route of [provider, ...(provider.fallbacks ?? [])]) { + if (names.has(route.name)) { + throw new Error( + `@samchon/graph: provider "${route.name}" is registered more than once; provenance needs one stable provider identity`, + ); + } + names.add(route.name); + if (route.languages.length === 0) { + throw new Error( + `@samchon/graph: provider "${route.name}" owns no language, so nothing can select it`, + ); + } + if (new Set(route.facts).size !== route.facts.length) { + throw new Error( + `@samchon/graph: provider "${route.name}" declares one fact family more than once`, + ); + } + if (route !== provider) { + if (route.fallbacks !== undefined) { + throw new Error( + `@samchon/graph: fallback provider "${route.name}" cannot declare another fallback tier`, + ); + } + if (!sameLanguages(route.languages, provider.languages)) { + throw new Error( + `@samchon/graph: fallback provider "${route.name}" does not own the same atomic languages as "${provider.name}"`, + ); + } + } } for (const language of provider.languages) { const existing = owners.get(language); @@ -154,3 +176,15 @@ function assertOneOwnerPerLanguage( } } } + +function sameLanguages( + left: readonly GraphLanguage[], + right: readonly GraphLanguage[], +): boolean { + const uniqueLeft = new Set(left); + const uniqueRight = new Set(right); + return ( + uniqueLeft.size === uniqueRight.size && + [...uniqueLeft].every((language) => uniqueRight.has(language)) + ); +} diff --git a/packages/graph/src/provider/sidecar/sidecarProvider.ts b/packages/graph/src/provider/sidecar/sidecarProvider.ts index 91297629..727a60d8 100644 --- a/packages/graph/src/provider/sidecar/sidecarProvider.ts +++ b/packages/graph/src/provider/sidecar/sidecarProvider.ts @@ -18,6 +18,9 @@ export function sidecarProvider( languages: props.languages, authority: props.authority, facts: props.facts, + ...(props.resolution === undefined + ? {} + : { resolution: props.resolution }), ...(props.buildInputs === undefined ? {} : { buildInputs: props.buildInputs }), @@ -88,6 +91,7 @@ export namespace sidecarProvider { languages: readonly GraphLanguage[]; authority: GraphProviderAuthority; facts: readonly GraphEdgeKind[]; + resolution?: IGraphProvider.IResolution; buildInputs?: IGraphProvider["buildInputs"]; resolve: IGraphProvider["resolve"]; prepare?: IGraphProvider["prepare"]; diff --git a/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts b/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts index c0caca88..609f7b74 100644 --- a/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts +++ b/packages/graph/src/provider/ttscgraph/ITtscGraphSnapshot.ts @@ -1,21 +1,19 @@ /** * One response frame of the `ttscgraph serve` protocol, as this client pins it. * - * Mirrored by hand from `serveResponse` in ttsc's - * `packages/ttsc/cmd/ttscgraph/serve.go`, first published at tag `v0.19.2` - * (`77192d97a`). There is no generator between the Go struct and this file, and - * there cannot be one this repository owns: the producer lives in another - * repository and ships as a prebuilt binary whose version the target project — - * not this package — chooses. That is why {@link ITtscGraphSnapshot.PROTOCOL_VERSION} - * exists, and why {@link parseTtscGraphSnapshot} validates every field on - * arrival instead of casting. + * Mirrored by hand from `serveResponse` and `serveGraphSnapshot` in ttsc's + * `packages/ttsc/cmd/ttscgraph`. There is no generator between the Go structs + * and this file, and there cannot be one this repository owns: the producer + * lives in another repository and ships as a prebuilt binary whose version the + * target project — not this package — chooses. That is why the envelope and + * native transaction have independent version pins, and why every field is + * validated on arrival instead of cast. * - * This is the envelope only. The `dump` it carries stays `unknown` here on - * purpose: {@link adaptTtscGraphDump} validates the body field by field into - * the product's own structures, so restating the body's wire shape would add a - * second contract to keep in sync with the same Go struct — and it would be the - * one the adapter never consults, which is the kind of duplicate that goes - * stale without anything failing. + * This is the envelope only. The native `snapshot` stays `unknown` here on + * purpose: {@link TtscGraphSnapshotStore} validates its transaction and shard + * fields before adapting changed shards into the product protocol. Restating + * that wire shape as a trusted TypeScript type would add a duplicate contract + * that can drift without protecting the runtime boundary. */ export type ITtscGraphSnapshot = | ITtscGraphSnapshot.IFailure @@ -57,6 +55,7 @@ export namespace ITtscGraphSnapshot { error: string; changed: false; dump?: undefined; + snapshot?: undefined; } /** A request the producer answered, whether or not the graph moved. */ @@ -75,8 +74,11 @@ export namespace ITtscGraphSnapshot { /** Whether the graph moved since the last snapshot. */ changed: boolean; - /** The snapshot body, present exactly when `changed` is true. */ - dump?: unknown; + /** The native shard transaction, present exactly when `changed` is true. */ + snapshot?: unknown; + + /** Legacy full dumps are refused by this incremental client. */ + dump?: undefined; } /** * The serve protocol version this client speaks. @@ -96,8 +98,11 @@ export namespace ITtscGraphSnapshot { */ export const PROTOCOL_VERSION = 1; + /** Native graph-shard transaction requested from compatible producers. */ + export const GRAPH_SNAPSHOT_VERSION = 1; + /** - * The version of the dump body this client adapts. + * The version of the compiler fact schema carried by native shards. * * Independent of {@link PROTOCOL_VERSION}: one versions the NDJSON envelope, * the other the graph document inside a changed frame. Keep this equal to diff --git a/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts b/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts index 91f772ae..e0494ce9 100644 --- a/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts +++ b/packages/graph/src/provider/ttscgraph/TtscGraphClient.ts @@ -1,13 +1,14 @@ import { ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import { compareOrdinal } from "@samchon/graph-sitter"; -import { freezeDeep } from "../../utils/freezeDeep"; -import { sealedMap } from "../../utils/sealedMap"; import { ownedProcess } from "../../utils/ownedProcess"; import { spawnableCommand } from "../../utils/spawnableCommand"; +import { GraphSnapshotProtocol } from "../GraphSnapshotProtocol"; import { IBulkGraphSession } from "../IBulkGraphSession"; -import { adaptTtscGraphDump } from "./adaptTtscGraphDump"; import { ITtscGraphSnapshot } from "./ITtscGraphSnapshot"; import { parseTtscGraphSnapshot } from "./parseTtscGraphSnapshot"; +import { TtscGraphSnapshotStore } from "./TtscGraphSnapshotStore"; +import { ttscGraphPhaseTrace } from "./ttscGraphPhaseTrace"; const DEFAULT_REQUEST_TIMEOUT_MS = 300_000; const DEFAULT_MAX_RESPONSE_BYTES = 256 * 1024 * 1024; @@ -18,6 +19,7 @@ interface NativeChild { stdoutChunks: string[]; stdoutBytes: number; stderr: string; + phaseTraceBuffer: string; exit: Promise; /** Resolves when every stream is finished, not merely when the child left. */ @@ -49,6 +51,9 @@ export class TtscGraphClient implements IBulkGraphSession { private readonly validate: ( snapshot: IBulkGraphSession.ISnapshot, ) => void; + private readonly protocol: GraphSnapshotProtocol.Store; + private readonly nativeProtocol: TtscGraphSnapshotStore; + private readonly phaseTrace: ttscGraphPhaseTrace.ITrace | undefined; private child: NativeChild | undefined; private readonly ownedChildren = new Set(); private readonly pending = new Map(); @@ -102,6 +107,9 @@ export class TtscGraphClient implements IBulkGraphSession { this.requestTimeoutMs = requestTimeoutMs; this.maxResponseBytes = maxResponseBytes; this.validate = options.validate ?? (() => undefined); + this.protocol = new GraphSnapshotProtocol.Store(this.root); + this.nativeProtocol = new TtscGraphSnapshotStore(this.root); + this.phaseTrace = ttscGraphPhaseTrace(); } public get generation(): number { @@ -120,8 +128,16 @@ export class TtscGraphClient implements IBulkGraphSession { } return this.enqueue(async () => { this.assertOpen(); + const refreshStarted = performance.now(); try { + const requestStarted = performance.now(); const response = await this.request(options.signal); + this.trace( + response.id, + response.mode, + "producer-roundtrip", + requestStarted, + ); if (response.mode === "error") { throw new Error(`ttscgraph: ${response.error}`); } @@ -136,6 +152,7 @@ export class TtscGraphClient implements IBulkGraphSession { response.capabilities, this.snapshot.provenance.capabilities, ); + this.trace(response.id, mode, "mcp-ready", refreshStarted); return { changed: false, generation: this.version, @@ -144,36 +161,39 @@ export class TtscGraphClient implements IBulkGraphSession { }; } - const adapted = adaptTtscGraphDump(response.dump, this.root); - const provenance: IBulkGraphSession.IProvenance = { - ...adapted.provenance, - protocolVersion: response.protocolVersion, - }; - assertCapabilitiesMatch(response.capabilities, provenance.capabilities); + const nativeStarted = performance.now(); + const prepared = this.nativeProtocol.prepare(response.snapshot, { + sequence: this.version + 1, + previous: this.snapshot, + }); + this.trace(response.id, mode, "native-normalize", nativeStarted); + assertCapabilitiesMatch( + response.capabilities, + prepared.capabilities, + ); if ( mode === "incremental" && this.snapshot !== undefined && - this.snapshot.provenance.universe !== provenance.universe + this.snapshot.provenance.universe !== prepared.universe ) { throw new Error( - "ttscgraph: incremental snapshot reports a build universe that moved since the last generation, so its program cannot have been reused", + "ttscgraph: incremental snapshot reports a build universe that " + + "moved since the last generation, so its program cannot have " + + "been reused", ); } - const next: IBulkGraphSession.ISnapshot = { - languages: ["typescript"], - nodes: adapted.nodes, - edges: adapted.edges, - diagnostics: adapted.diagnostics, - sources: adapted.sources, - provenance, - warnings: adapted.warnings, - }; - next.sources = sealedMap(next.sources, "the ttscgraph snapshot"); - freezeDeep(next, "the ttscgraph snapshot"); - this.validate(next); + const commonStarted = performance.now(); + const next = this.protocol.apply(prepared.frames, { + signal: options.signal, + warnings: prepared.warnings, + validate: this.validate, + }); + prepared.commit(); this.snapshot = next; this.childHasSnapshot = true; this.version += 1; + this.trace(response.id, mode, "common-commit", commonStarted); + this.trace(response.id, mode, "mcp-ready", refreshStarted); return { changed: true, generation: this.version, @@ -202,6 +222,20 @@ export class TtscGraphClient implements IBulkGraphSession { return this.closing; } + private trace( + request: number, + mode: string, + phase: ttscGraphPhaseTrace.IEvent["phase"], + started: number, + ): void { + this.phaseTrace?.event({ + request, + mode, + phase, + durationMs: performance.now() - started, + }); + } + private request(signal?: AbortSignal): Promise { if (signal?.aborted) throw cancelledError(signal); const child = this.ensureChild(); @@ -236,12 +270,14 @@ export class TtscGraphClient implements IBulkGraphSession { pending.abort!(); return; } - child.process.stdin.write(`${JSON.stringify({ id })}\n`, (error) => { - /* c8 ignore start -- Windows keeps the inherited named-pipe read - * handle until child exit. This callback-specific EPIPE path is - * POSIX-only and is exercised there. */ - if (error === null || error === undefined) return; - if (this.pending.get(id) !== pending) return; + child.process.stdin.write( + `${JSON.stringify({ id, graphSnapshotVersion: ITtscGraphSnapshot.GRAPH_SNAPSHOT_VERSION })}\n`, + (error) => { + /* c8 ignore start -- Windows keeps the inherited named-pipe read + * handle until child exit. This callback-specific EPIPE path is + * POSIX-only and is exercised there. */ + if (error === null || error === undefined) return; + if (this.pending.get(id) !== pending) return; // EPIPE says our end of the pipe closed, which is never the diagnosis: // the child exited before it could accept the request, and why it did // is whatever it printed on the way out. The timeout path beside this @@ -263,17 +299,18 @@ export class TtscGraphClient implements IBulkGraphSession { // broken pipe does not prove a dead child: a producer that closes its // stdin and keeps running never fires `close`, and waiting for it would // hold the request until the timeout for a fault that is already known. - void drained(child).then(() => { - if (this.pending.get(id) !== pending) return; - this.failChild( - child, - new Error( - `ttscgraph: could not request snapshot: ${error.message}${TtscGraphClient.exitSuffix(child.process)}${stderrSuffix(child)}`, - ), - ); - }); - /* c8 ignore stop */ - }); + void drained(child).then(() => { + if (this.pending.get(id) !== pending) return; + this.failChild( + child, + new Error( + `ttscgraph: could not request snapshot: ${error.message}${TtscGraphClient.exitSuffix(child.process)}${stderrSuffix(child)}`, + ), + ); + }); + /* c8 ignore stop */ + }, + ); }); } @@ -326,6 +363,7 @@ export class TtscGraphClient implements IBulkGraphSession { stdoutChunks: [], stdoutBytes: 0, stderr: "", + phaseTraceBuffer: "", exit: ownedProcess.exit(spawned), // `close` and not `exit`, which is the whole point. `ownedProcess.exit` // settles on whichever of error, exit or close arrives first, and exit @@ -337,12 +375,19 @@ export class TtscGraphClient implements IBulkGraphSession { }; this.child = child; this.childHasSnapshot = false; + this.nativeProtocol.reset(); this.ownedChildren.add(child); spawned.stdout.setEncoding("utf8"); spawned.stderr.setEncoding("utf8"); spawned.stdout.on("data", (chunk: string) => this.consume(child, chunk)); spawned.stderr.on("data", (chunk: string) => { child.stderr = (child.stderr + chunk).slice(-64 * 1024); + if (this.phaseTrace !== undefined) { + child.phaseTraceBuffer = this.phaseTrace.forwardProducer( + child.phaseTraceBuffer, + chunk, + ); + } }); /* c8 ignore start -- direct POSIX spawn failures are exercised on POSIX. * Windows starts a stable Job Object supervisor first and reports a nested @@ -638,10 +683,8 @@ function assertCapabilitiesMatch( envelope: readonly string[], dump: readonly string[], ): void { - const compare = (left: string, right: string): number => - left < right ? -1 : left > right ? 1 : 0; - const left = JSON.stringify([...envelope].sort(compare)); - const right = JSON.stringify([...dump].sort(compare)); + const left = JSON.stringify([...new Set(envelope)].sort(compareOrdinal)); + const right = JSON.stringify([...new Set(dump)].sort(compareOrdinal)); if (left !== right) { throw new Error( "ttscgraph: response capabilities disagree with the snapshot provenance", diff --git a/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts b/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts new file mode 100644 index 00000000..aeb725ee --- /dev/null +++ b/packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore.ts @@ -0,0 +1,903 @@ +import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; +import path from "node:path"; + +import { ISamchonGraphCoverage } from "../../structures"; +import { GRAPH_EDGE_KINDS } from "../../typings"; +import { GraphSnapshotProtocol } from "../GraphSnapshotProtocol"; +import { IBulkGraphSession } from "../IBulkGraphSession"; +import { adaptTtscGraphDump } from "./adaptTtscGraphDump"; +import { ITtscGraphSnapshot } from "./ITtscGraphSnapshot"; + +interface INativeShard { + key: string; + source?: Record; + config?: Record; + nodes: unknown[]; + edges: unknown[]; + diagnostics: unknown[]; +} + +interface INativeTransaction { + protocolVersion: number; + schemaVersion: number; + project: string; + tsconfig: string; + producer: Record; + capabilities: string[]; + universe: Record; + sequence: number; + generation: string; + baseSequence?: number; + baseGeneration?: string; + upserts: { + digest: string; + shard: INativeShard; + rawShard: Record; + }[]; + deletes: string[]; + manifest: { key: string; digest: string }[]; +} + +interface ICommittedNativeShard { + digest: string; + shard: INativeShard; +} + +interface INativeShardSummary { + key: string; + sourceFile?: string; + configFile?: string; + configDigest?: string; + nodeIds: string[]; + edgeTargets: string[]; +} + +interface INativeValidation { + summaries: Map; + nodeById: Map; + sourceOwners: Map; + configs: Map; + edgeOwnersByTarget: Map>; +} + +/** Validates native ttsc shards and maps only their deltas into common shards. */ +export class TtscGraphSnapshotStore { + public static readonly VERSION = 1; + + private sequence: number | undefined; + private generation: string | undefined; + private project: string | undefined; + private tsconfig: string | undefined; + private native = new Map(); + private normalized = new Map(); + private validation = emptyNativeValidation(); + private coverageKey: string | undefined; + + public constructor(private readonly root: string) {} + + /** A new native child owns a new sequence space and must start completely. */ + public reset(): void { + this.sequence = undefined; + this.generation = undefined; + this.project = undefined; + this.tsconfig = undefined; + this.native = new Map(); + this.normalized = new Map(); + this.validation = emptyNativeValidation(); + this.coverageKey = undefined; + } + + /** + * Prepare one atomic common-protocol transaction without publishing native + * state. The caller commits only after the common store and product validator + * have accepted the same generation. + */ + public prepare( + input: unknown, + options: { + sequence: number; + previous?: IBulkGraphSession.ISnapshot; + }, + ): TtscGraphSnapshotStore.IPrepared { + const transaction = transactionOf(input); + this.assertCoordinates(transaction); + const touched = new Set(); + const nextNative = + transaction.baseGeneration === undefined + ? new Map() + : new Map(this.native); + for (const key of transaction.deletes) { + assertShardKey(key); + if (touched.has(key)) duplicateTouch(key); + touched.add(key); + if (!nextNative.delete(key)) { + throw new Error( + `ttscgraph: native transaction deletes unknown shard ${key}`, + ); + } + } + for (const upsert of transaction.upserts) { + assertShardKey(upsert.shard.key); + if (touched.has(upsert.shard.key)) duplicateTouch(upsert.shard.key); + touched.add(upsert.shard.key); + const digest = nativeDigest(upsert.rawShard); + if (digest !== upsert.digest) { + throw new Error( + `ttscgraph: native shard ${upsert.shard.key} digest ` + + `${upsert.digest} does not match ${digest}`, + ); + } + nextNative.set(upsert.shard.key, { + digest, + shard: upsert.shard, + }); + } + assertNativeManifest(transaction, nextNative); + assertNativeGeneration(transaction); + const nextValidation = prepareNativeGenerationFacts( + transaction, + transaction.baseGeneration === undefined + ? emptyNativeValidation() + : this.validation, + ); + const nodeById = nextValidation.nodeById; + + const provenance = nativeProvenance(transaction, nextNative); + const metadataInput = { + project: transaction.project, + tsconfig: transaction.tsconfig, + provenance, + diagnostics: [], + nodes: [], + edges: [], + }; + const adapterContext = adaptTtscGraphDump.prepareContext( + metadataInput, + this.root, + ); + const metadata = adapterContext.adapt(metadataInput); + const nextNormalized = + transaction.baseGeneration === undefined + ? new Map() + : new Map(this.normalized); + for (const key of transaction.deletes) nextNormalized.delete(key); + for (const upsert of transaction.upserts) { + nextNormalized.set( + upsert.shard.key, + adaptNativeShard( + upsert.shard, + transaction, + adapterContext, + nodeById, + metadata, + this.root, + ), + ); + } + + const hello = helloOf(metadata, transaction.schemaVersion); + const coverage = coverageShard(metadata, hello); + if ( + this.coverageKey !== undefined && + this.coverageKey !== coverage.key + ) { + nextNormalized.delete(this.coverageKey); + } + nextNormalized.set(coverage.key, coverage); + + const ordered = [...nextNormalized].sort(([left], [right]) => + compareText(left, right), + ); + const manifest = ordered.map(([key, shard]) => ({ + key, + digest: GraphSnapshotProtocol.shardDigest(shard), + })); + const sources = ordered.flatMap(([, shard]) => shard.sources); + const canReuse = + transaction.baseGeneration !== undefined && + options.previous?.protocol !== undefined; + const begin: GraphSnapshotProtocol.IBegin = { + type: "begin", + sequence: options.sequence, + generation: transaction.generation, + ...(canReuse + ? { + baseSequence: options.previous!.protocol!.sequence, + baseGeneration: options.previous!.protocol!.generation, + } + : {}), + universe: metadata.provenance.universe, + manifest: GraphSnapshotProtocol.manifestDigest(sources), + targets: [metadata.target], + }; + const assembled = assembledSnapshot(hello, begin, ordered); + const factDigest = GraphSnapshotProtocol.factDigest(assembled); + const frames: GraphSnapshotProtocol.Frame[] = [hello, begin]; + const previousNormalized = canReuse ? this.normalized : new Map(); + for (const key of previousNormalized.keys()) { + if (!nextNormalized.has(key)) frames.push({ type: "deleteShard", key }); + } + for (const [key, shard] of ordered) { + const digest = GraphSnapshotProtocol.shardDigest(shard); + const previous = previousNormalized.get(key); + if ( + previous === undefined || + GraphSnapshotProtocol.shardDigest(previous) !== digest + ) { + frames.push({ type: "upsertShard", digest, shard }); + } + } + frames.push({ + type: "commit", + sequence: begin.sequence, + generation: begin.generation, + shards: manifest, + factDigest, + }); + return { + frames, + capabilities: hello.capabilities, + universe: begin.universe, + warnings: metadata.warnings, + commit: () => { + this.sequence = transaction.sequence; + this.generation = transaction.generation; + this.project = transaction.project; + this.tsconfig = transaction.tsconfig; + this.native = nextNative; + this.normalized = nextNormalized; + this.validation = nextValidation; + this.coverageKey = coverage.key; + }, + }; + } + + private assertCoordinates(transaction: INativeTransaction): void { + if (transaction.protocolVersion !== TtscGraphSnapshotStore.VERSION) { + throw new Error( + "ttscgraph: native snapshot protocol " + + `v${String(transaction.protocolVersion)} is incompatible with ` + + `client v${String(TtscGraphSnapshotStore.VERSION)}`, + ); + } + if ( + !ITtscGraphSnapshot.SUPPORTED_DUMP_SCHEMA_VERSIONS.includes( + transaction.schemaVersion, + ) + ) { + throw new Error( + "ttscgraph: native snapshot uses unsupported dump schema " + + `v${String(transaction.schemaVersion)}`, + ); + } + assertDigest(transaction.generation, "native transaction generation"); + if (this.sequence === undefined || this.generation === undefined) { + if ( + transaction.sequence !== 1 || + transaction.baseSequence !== undefined || + transaction.baseGeneration !== undefined || + transaction.deletes.length !== 0 + ) { + throw new Error( + "ttscgraph: initial native transaction is not a complete generation", + ); + } + return; + } + if ( + transaction.sequence !== this.sequence + 1 || + transaction.baseSequence !== this.sequence || + transaction.baseGeneration !== this.generation + ) { + throw new Error( + "ttscgraph: native transaction has stale base " + + `${String(transaction.baseSequence)}/` + + String(transaction.baseGeneration), + ); + } + if ( + transaction.project !== this.project || + transaction.tsconfig !== this.tsconfig + ) { + throw new Error( + "ttscgraph: native transaction changed its resident project coordinates", + ); + } + } +} + +export namespace TtscGraphSnapshotStore { + export interface IPrepared { + frames: GraphSnapshotProtocol.Frame[]; + capabilities: string[]; + universe: string; + warnings: string[]; + commit: () => void; + } +} + +function adaptNativeShard( + shard: INativeShard, + transaction: INativeTransaction, + adapterContext: ReturnType, + nodeById: ReadonlyMap, + metadata: ReturnType, + root: string, +): GraphSnapshotProtocol.IShard { + const localIds = new Set( + shard.nodes.map((node, index) => + stringOf(objectOf(node, `${shard.key}.nodes[${String(index)}]`).id, "node.id"), + ), + ); + const nodes = [...shard.nodes]; + const includedIds = new Set(localIds); + for (let index = 0; index < shard.edges.length; index++) { + const edge = objectOf( + shard.edges[index], + `${shard.key}.edges[${String(index)}]`, + ); + const target = stringOf(edge.to, `${shard.key}.edges[${String(index)}].to`); + if (!includedIds.has(target)) { + nodes.push(nodeById.get(target)!); + includedIds.add(target); + } + } + const adapted = adapterContext.adapt({ + project: transaction.project, + tsconfig: transaction.tsconfig, + provenance: {}, + diagnostics: shard.diagnostics, + nodes, + edges: shard.edges, + }); + const localModuleFiles = new Set(); + for (const node of shard.nodes) { + const raw = objectOf(node, `${shard.key}.node`); + if (raw.kind === "module") { + localModuleFiles.add(stringOf(raw.file, `${shard.key}.node.file`)); + } + } + const sourceFile = shard.source?.file ?? shard.config?.file; + const sources: GraphSnapshotProtocol.ISource[] = []; + if (sourceFile !== undefined) { + const file = stringOf(sourceFile, `${shard.key}.source.file`); + const canonical = file.startsWith("bundled:///") + ? file + : path.resolve(root, file); + // `nativeProvenance` is derived from this same validated shard set, and + // `adaptTtscGraphDump` adds its validated configuration universe. + const digest = metadata.sources.get(canonical)!; + sources.push({ file: canonical, ...digest }); + } + return { + key: shard.key, + target: metadata.target, + languages: ["typescript"], + nodes: adapted.nodes.filter( + (node) => localIds.has(node.id) || localModuleFiles.has(node.id), + ), + edges: adapted.edges, + diagnostics: adapted.diagnostics, + coverage: [], + unresolved: [], + sources, + }; +} + +function nativeProvenance( + transaction: INativeTransaction, + shards: ReadonlyMap, +): Record { + const sources: Record[] = []; + for (const { shard } of shards.values()) { + if (shard.source !== undefined) sources.push({ ...shard.source }); + } + sources.sort((left, right) => + compareUtf8(stringOf(left.file, "source.file"), stringOf(right.file, "source.file")), + ); + return { + schemaVersion: transaction.schemaVersion, + capabilities: [...transaction.capabilities], + producer: transaction.producer, + universe: transaction.universe, + sources, + }; +} + +function helloOf( + metadata: ReturnType, + schemaVersion: number, +): GraphSnapshotProtocol.IHello { + return { + type: "hello", + protocolVersion: GraphSnapshotProtocol.VERSION, + schemaVersion: GraphSnapshotProtocol.SCHEMA_VERSION, + producerSchemaVersion: schemaVersion, + provider: metadata.provenance.provider, + producer: metadata.provenance.tool, + producerVersion: metadata.provenance.toolVersion, + compilerVersion: metadata.provenance.compilerVersion, + languages: ["typescript"], + authority: metadata.provenance.authority, + supportedFacts: [...metadata.provenance.facts], + capabilities: [...metadata.provenance.capabilities], + }; +} + +function coverageShard( + metadata: ReturnType, + hello: GraphSnapshotProtocol.IHello, +): GraphSnapshotProtocol.IShard { + const supported = new Set(hello.supportedFacts); + const coverage: ISamchonGraphCoverage[] = GRAPH_EDGE_KINDS.map((family) => ({ + provider: hello.provider, + language: "typescript", + target: metadata.target, + family, + state: supported.has(family) ? "partial" : "unsupported", + })); + return { + key: `0:coverage:${JSON.stringify([ + GraphSnapshotProtocol.VERSION, + hello.provider, + hello.producerVersion, + hello.compilerVersion, + "typescript", + metadata.target, + metadata.provenance.universe, + ])}`, + target: metadata.target, + languages: ["typescript"], + nodes: [], + edges: [], + diagnostics: [], + coverage, + unresolved: hello.supportedFacts.map((family) => ({ + provider: hello.provider, + language: "typescript", + target: metadata.target, + universe: metadata.provenance.universe, + family, + evidence: { file: metadata.target, startLine: 1, startCol: 1 }, + reason: "provider-gap", + })), + sources: [], + }; +} + +function assembledSnapshot( + hello: GraphSnapshotProtocol.IHello, + begin: GraphSnapshotProtocol.IBegin, + shards: readonly [string, GraphSnapshotProtocol.IShard][], +): Parameters[0] { + const values = shards.map(([, shard]) => shard); + return { + languages: [...hello.languages], + nodes: values.flatMap((shard) => shard.nodes), + edges: values.flatMap((shard) => shard.edges), + diagnostics: values.flatMap((shard) => shard.diagnostics), + coverage: values.flatMap((shard) => shard.coverage), + unresolved: values.flatMap((shard) => shard.unresolved), + provenance: { + provider: hello.provider, + authority: hello.authority, + facts: [...hello.supportedFacts], + schemaVersion: hello.producerSchemaVersion, + tool: hello.producer, + toolVersion: hello.producerVersion, + compilerVersion: hello.compilerVersion, + protocolVersion: hello.protocolVersion, + universe: begin.universe, + capabilities: [...hello.capabilities], + }, + }; +} + +function prepareNativeGenerationFacts( + transaction: INativeTransaction, + previous: INativeValidation, +): INativeValidation { + // Raw node, edge, and diagnostic arrays are revisited for changed shards and + // the retained target nodes their cross-shard edges depend on, never by an + // unrelated retained-shard scan. The compact indexes and common protocol + // reconstruction still make one O(N) pass over the generation to bind an + // atomic commit; only dependency-bounded raw-fact parsing, not total + // normalization work, is delta-sized. + const next: INativeValidation = { + summaries: new Map(previous.summaries), + nodeById: new Map(previous.nodeById), + sourceOwners: new Map(previous.sourceOwners), + configs: new Map(previous.configs), + edgeOwnersByTarget: new Map(previous.edgeOwnersByTarget), + }; + const affectedTargets = new Set(); + const mutableEdgeOwners = (target: string): Set => { + const current = next.edgeOwnersByTarget.get(target) ?? new Set(); + const copied = new Set(current); + next.edgeOwnersByTarget.set(target, copied); + return copied; + }; + const remove = (key: string): void => { + const summary = next.summaries.get(key); + if (summary === undefined) return; + next.summaries.delete(key); + if (summary.sourceFile !== undefined) { + next.sourceOwners.delete(summary.sourceFile); + } + if (summary.configFile !== undefined) { + next.configs.delete(summary.configFile); + } + for (const id of summary.nodeIds) { + next.nodeById.delete(id); + affectedTargets.add(id); + } + for (const target of summary.edgeTargets) { + const owners = mutableEdgeOwners(target); + owners.delete(key); + if (owners.size === 0) next.edgeOwnersByTarget.delete(target); + } + }; + + for (const key of transaction.deletes) remove(key); + // An identity-stable external or metadata shard is replaced by an upsert, + // so remove all old ownership before installing any new ownership. This also + // permits an atomic node move between two simultaneously changed shards. + for (const upsert of transaction.upserts) remove(upsert.shard.key); + + for (const upsert of transaction.upserts) { + const summary = summarizeNativeShard(upsert.shard); + if (summary.sourceFile !== undefined) { + if (next.sourceOwners.has(summary.sourceFile)) { + throw new Error( + `ttscgraph: native source ${summary.sourceFile} has two shards`, + ); + } + next.sourceOwners.set(summary.sourceFile, summary.key); + } + if ( + summary.configFile !== undefined && + summary.configDigest !== undefined + ) { + if (next.configs.has(summary.configFile)) { + throw new Error( + `ttscgraph: native config ${summary.configFile} has two shards`, + ); + } + next.configs.set(summary.configFile, summary.configDigest); + } + for (let index = 0; index < summary.nodeIds.length; index++) { + const id = summary.nodeIds[index]!; + if (next.nodeById.has(id)) { + throw new Error(`ttscgraph: native node ${id} has two owners`); + } + next.nodeById.set(id, upsert.shard.nodes[index]); + affectedTargets.add(id); + } + for (const target of summary.edgeTargets) { + mutableEdgeOwners(target).add(summary.key); + affectedTargets.add(target); + } + next.summaries.set(summary.key, summary); + } + + for (const target of affectedTargets) { + if ( + next.edgeOwnersByTarget.has(target) && + !next.nodeById.has(target) + ) { + throw new Error(`ttscgraph: native edge target is absent: ${target}`); + } + } + assertNativeUniverseConfigs(transaction, next.configs); + return next; +} + +function summarizeNativeShard(shard: INativeShard): INativeShardSummary { + const key = shard.key; + if (shard.source !== undefined && shard.config !== undefined) { + throw new Error(`ttscgraph: native shard ${key} owns two input kinds`); + } + const sourceFile = + shard.source === undefined + ? undefined + : stringOf(shard.source.file, `${key}.source.file`); + const configFile = + shard.config === undefined + ? undefined + : stringOf(shard.config.file, `${key}.config.file`); + const configDigest = + shard.config === undefined + ? undefined + : stringOf(shard.config.digest, `${key}.config.digest`); + if ( + configFile !== undefined && + (shard.nodes.length !== 0 || shard.edges.length !== 0) + ) { + throw new Error(`ttscgraph: native config shard ${key} owns facts`); + } + if (sourceFile === undefined && shard.edges.length !== 0) { + throw new Error(`ttscgraph: native non-source shard ${key} owns edges`); + } + const nodeIds: string[] = []; + const localIds = new Set(); + for (let index = 0; index < shard.nodes.length; index++) { + const node = objectOf(shard.nodes[index], `${key}.nodes[${String(index)}]`); + const id = stringOf(node.id, `${key}.nodes[${String(index)}].id`); + const file = stringOf(node.file, `${key}.nodes[${String(index)}].file`); + const external = booleanOf( + node.external, + `${key}.nodes[${String(index)}].external`, + ); + if ( + (sourceFile !== undefined && (external || file !== sourceFile)) || + (sourceFile === undefined && !external) + ) { + throw new Error(`ttscgraph: native shard ${key} misowns node ${id}`); + } + if (localIds.has(id)) { + throw new Error(`ttscgraph: native node ${id} has two owners`); + } + localIds.add(id); + nodeIds.push(id); + } + for (let index = 0; index < shard.diagnostics.length; index++) { + const diagnostic = objectOf( + shard.diagnostics[index], + `${key}.diagnostics[${String(index)}]`, + ); + const file = stringOf( + diagnostic.file, + `${key}.diagnostics[${String(index)}].file`, + ); + if ( + (sourceFile !== undefined && file !== sourceFile) || + (configFile !== undefined && file !== configFile) || + (sourceFile === undefined && configFile === undefined && file !== "") + ) { + throw new Error(`ttscgraph: native shard ${key} misowns diagnostic`); + } + } + const edgeTargets = new Set(); + for (let index = 0; index < shard.edges.length; index++) { + const edge = objectOf(shard.edges[index], `${key}.edges[${String(index)}]`); + const from = stringOf(edge.from, `${key}.edges[${String(index)}].from`); + const to = stringOf(edge.to, `${key}.edges[${String(index)}].to`); + if (!localIds.has(from)) { + throw new Error(`ttscgraph: native shard ${key} misowns edge ${from}`); + } + edgeTargets.add(to); + } + return { + key, + ...(sourceFile === undefined ? {} : { sourceFile }), + ...(configFile === undefined ? {} : { configFile, configDigest }), + nodeIds, + edgeTargets: [...edgeTargets], + }; +} + +function assertNativeUniverseConfigs( + transaction: INativeTransaction, + configs: ReadonlyMap, +): void { + const universe = objectOf(transaction.universe, "native universe"); + const universeConfigs = arrayOf(universe.configs, "native universe.configs"); + if (universeConfigs.length !== configs.size) { + throw new Error("ttscgraph: native config shards do not cover the universe"); + } + const seen = new Set(); + for (let index = 0; index < universeConfigs.length; index++) { + const config = objectOf( + universeConfigs[index], + `native universe.configs[${String(index)}]`, + ); + const file = stringOf(config.file, "native config.file"); + const digest = stringOf(config.digest, "native config.digest"); + if (seen.has(file) || configs.get(file) !== digest) { + throw new Error(`ttscgraph: native config shard disagrees at ${file}`); + } + seen.add(file); + } +} + +function emptyNativeValidation(): INativeValidation { + return { + summaries: new Map(), + nodeById: new Map(), + sourceOwners: new Map(), + configs: new Map(), + edgeOwnersByTarget: new Map(), + }; +} + +function assertNativeManifest( + transaction: INativeTransaction, + shards: ReadonlyMap, +): void { + if (transaction.manifest.length !== shards.size) { + throw new Error("ttscgraph: native manifest does not cover the generation"); + } + for (let index = 0; index < transaction.manifest.length; index++) { + const entry = transaction.manifest[index]!; + assertShardKey(entry.key); + assertDigest(entry.digest, "native manifest digest"); + if ( + index !== 0 && + compareUtf8(transaction.manifest[index - 1]!.key, entry.key) >= 0 + ) { + throw new Error("ttscgraph: native manifest is not strictly key-sorted"); + } + if (shards.get(entry.key)?.digest !== entry.digest) { + throw new Error(`ttscgraph: native manifest disagrees at ${entry.key}`); + } + } +} + +function assertNativeGeneration(transaction: INativeTransaction): void { + const generation = nativeDigest({ + tsconfig: transaction.tsconfig, + producer: transaction.producer, + capabilities: transaction.capabilities, + universe: transaction.universe, + manifest: transaction.manifest, + }); + if (generation !== transaction.generation) { + throw new Error( + `ttscgraph: native generation ${transaction.generation} does not match ${generation}`, + ); + } +} + +function transactionOf(value: unknown): INativeTransaction { + const raw = objectOf(value, "native snapshot"); + const transaction: INativeTransaction = { + protocolVersion: integerOf(raw.protocolVersion, "native protocolVersion"), + schemaVersion: integerOf(raw.schemaVersion, "native schemaVersion"), + project: stringOf(raw.project, "native project"), + tsconfig: stringOf(raw.tsconfig, "native tsconfig"), + producer: objectOf(raw.producer, "native producer"), + capabilities: arrayOf(raw.capabilities, "native capabilities").map( + (entry, index) => stringOf(entry, `native capabilities[${String(index)}]`), + ), + universe: objectOf(raw.universe, "native universe"), + sequence: integerOf(raw.sequence, "native sequence"), + generation: stringOf(raw.generation, "native generation"), + upserts: arrayOf(raw.upserts, "native upserts").map((entry, index) => { + const upsert = objectOf(entry, `native upserts[${String(index)}]`); + const rawShard = objectOf( + upsert.shard, + `native upserts[${String(index)}].shard`, + ); + return { + digest: stringOf(upsert.digest, "native upsert.digest"), + shard: shardOf(rawShard, `native upserts[${String(index)}].shard`), + rawShard, + }; + }), + deletes: arrayOf(raw.deletes, "native deletes").map((entry, index) => + stringOf(entry, `native deletes[${String(index)}]`), + ), + manifest: arrayOf(raw.manifest, "native manifest").map((entry, index) => { + const reference = objectOf(entry, `native manifest[${String(index)}]`); + return { + key: stringOf(reference.key, "native manifest.key"), + digest: stringOf(reference.digest, "native manifest.digest"), + }; + }), + }; + if (raw.baseSequence !== undefined) { + transaction.baseSequence = integerOf( + raw.baseSequence, + "native baseSequence", + ); + } + if (raw.baseGeneration !== undefined) { + transaction.baseGeneration = stringOf( + raw.baseGeneration, + "native baseGeneration", + ); + } + if ( + (transaction.baseSequence === undefined) !== + (transaction.baseGeneration === undefined) + ) { + throw new Error("ttscgraph: native base coordinates are incomplete"); + } + return transaction; +} + +function shardOf(value: unknown, label: string): INativeShard { + const raw = objectOf(value, label); + const shard: INativeShard = { + key: stringOf(raw.key, `${label}.key`), + nodes: arrayOf(raw.nodes, `${label}.nodes`), + edges: arrayOf(raw.edges, `${label}.edges`), + diagnostics: arrayOf(raw.diagnostics, `${label}.diagnostics`), + }; + if (raw.source !== undefined) { + shard.source = objectOf(raw.source, `${label}.source`); + } + if (raw.config !== undefined) { + shard.config = objectOf(raw.config, `${label}.config`); + } + return shard; +} + +function nativeDigest(value: unknown): string { + return createHash("sha256").update(goJSON(value)).digest("hex"); +} + +function goJSON(value: unknown): string { + return JSON.stringify(value) + .replaceAll("<", "\\u003c") + .replaceAll(">", "\\u003e") + .replaceAll("&", "\\u0026") + .replaceAll("\u2028", "\\u2028") + .replaceAll("\u2029", "\\u2029"); +} + +function compareUtf8(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); +} + +function compareText(left: string, right: string): number { + // Map keys are unique, so shard ordering never compares equal identities. + return left < right ? -1 : 1; +} + +function duplicateTouch(key: string): never { + throw new Error(`ttscgraph: native transaction touches shard ${key} twice`); +} + +function assertShardKey(key: string): void { + if (key === "" || key.includes("\0")) { + throw new Error(`ttscgraph: native shard key is invalid: ${key}`); + } + if (key.startsWith("0:coverage:")) { + throw new Error( + `ttscgraph: native shard uses reserved normalized namespace: ${key}`, + ); + } +} + +function assertDigest(value: string, label: string): void { + if (!/^[a-f0-9]{64}$/u.test(value)) { + throw new Error(`ttscgraph: ${label} must be a SHA-256 digest`); + } +} + +function objectOf(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`ttscgraph: ${label} must be an object`); + } + return value as Record; +} + +function arrayOf(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) { + throw new Error(`ttscgraph: ${label} must be an array`); + } + return value; +} + +function stringOf(value: unknown, label: string): string { + if (typeof value !== "string") { + throw new Error(`ttscgraph: ${label} must be a string`); + } + return value; +} + +function booleanOf(value: unknown, label: string): boolean { + if (typeof value !== "boolean") { + throw new Error(`ttscgraph: ${label} must be boolean`); + } + return value; +} + +function integerOf(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + throw new Error(`ttscgraph: ${label} must be a positive safe integer`); + } + return value as number; +} diff --git a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts index 99080c71..2e38e4aa 100644 --- a/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts +++ b/packages/graph/src/provider/ttscgraph/adaptTtscGraphDump.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import fs from "node:fs"; import path from "node:path"; import { compareOrdinal } from "@samchon/graph-sitter"; @@ -27,6 +28,7 @@ import { ITtscGraphSnapshot } from "./ITtscGraphSnapshot"; * version of the frame that carried it. */ interface IAdaptedDump { + target: string; nodes: ISamchonGraphNode[]; edges: ISamchonGraphEdge[]; diagnostics: ISamchonGraphDiagnostic[]; @@ -35,6 +37,17 @@ interface IAdaptedDump { warnings: string[]; } +/** Metadata and source evidence validated once for one native generation. */ +interface ITtscGraphDumpContext { + target: string; + capabilities: string[]; + manifest: ReadonlyMap; + sources: Map; + provenance: Omit; + warnings: string[]; + adapt: (input: unknown) => IAdaptedDump; +} + /** * Adapt a `ttscgraph serve` dump to one strict TypeScript language slice. * @@ -55,33 +68,19 @@ interface IAdaptedDump { export function adaptTtscGraphDump( input: unknown, expectedRoot: string, +): IAdaptedDump { + return prepareTtscGraphDumpContext(input, expectedRoot).adapt(input); +} + +function adaptTtscGraphDumpWithContext( + input: unknown, + context: ITtscGraphDumpContext, ): IAdaptedDump { const dump = objectOf(input, "dump"); - const rawProvenance = objectOf(dump.provenance, "dump.provenance"); - const schemaVersion = rawProvenance.schemaVersion; - if ( - !Number.isSafeInteger(schemaVersion) || - !ITtscGraphSnapshot.SUPPORTED_DUMP_SCHEMA_VERSIONS.includes( - schemaVersion as number, - ) - ) { - throw new Error( - `ttscgraph: dump is schema ${ - Number.isSafeInteger(schemaVersion) - ? `v${String(schemaVersion)}` - : "unknown" - }, this client reads ${ITtscGraphSnapshot.SUPPORTED_DUMP_SCHEMA_VERSIONS.map( - (version) => `v${String(version)}`, - ).join(" and ")}. Install a matching ttsc (the binary resolves from the target project, or from TTSC_GRAPH_BINARY).`, - ); - } - const warnings: string[] = []; - const project = stringOf(dump.project, "dump.project"); - if (!samePath(project, expectedRoot)) { - throw new Error( - `ttscgraph: response project ${project} does not match ${expectedRoot}`, - ); - } + const warnings = [...context.warnings]; + const target = context.target; + const capabilities = context.capabilities; + const manifest = context.manifest; const rawNodes = arrayOf(dump.nodes, "dump.nodes"); const rawEdges = arrayOf(dump.edges, "dump.edges"); const moduleIds = new Map(); @@ -236,12 +235,6 @@ export function adaptTtscGraphDump( edges.push(edge); } - const capabilities = stringArrayOf( - objectOf(dump.provenance, "dump.provenance").capabilities, - "dump.provenance.capabilities", - ); - const manifest = manifestOf(dump.provenance); - mergeConfigurationSources(manifest, dump.provenance, capabilities); for (const file of [...factFiles].sort(compareOrdinal)) { if (!manifest.has(file)) { throw new Error( @@ -249,16 +242,6 @@ export function adaptTtscGraphDump( ); } } - const sources = new Map(); - // Preserve the complete compiler-owned manifest. Relative identities become - // absolute keys for the bulk-session contract; identities that are already - // absolute stay canonical, and bundled virtual identities must never pass - // through `path.resolve`, which would turn them into unrelated disk paths. - for (const [file, digest] of [...manifest].sort(([left], [right]) => - compareOrdinal(left, right), - )) { - sources.set(sourceManifestKey(expectedRoot, file), digest); - } const diagnostics = capabilities.includes( ITtscGraphSnapshot.CAPABILITY_DIAGNOSTICS, @@ -267,17 +250,80 @@ export function adaptTtscGraphDump( : refuseDiagnostics(dump.diagnostics, warnings); return { + target, nodes, edges, diagnostics, + sources: context.sources, + provenance: context.provenance, + warnings, + }; +} + +/** Validate the generation-wide coordinates and source manifest once. */ +function prepareTtscGraphDumpContext( + input: unknown, + expectedRoot: string, +): ITtscGraphDumpContext { + const dump = objectOf(input, "dump"); + const rawProvenance = objectOf(dump.provenance, "dump.provenance"); + const schemaVersion = rawProvenance.schemaVersion; + if ( + !Number.isSafeInteger(schemaVersion) || + !ITtscGraphSnapshot.SUPPORTED_DUMP_SCHEMA_VERSIONS.includes( + schemaVersion as number, + ) + ) { + throw new Error( + `ttscgraph: dump is schema ${ + Number.isSafeInteger(schemaVersion) + ? `v${String(schemaVersion)}` + : "unknown" + }, this client reads ${ITtscGraphSnapshot.SUPPORTED_DUMP_SCHEMA_VERSIONS.map( + (version) => `v${String(version)}`, + ).join(" and ")}. Install a matching ttsc (the binary resolves from the target project, or from TTSC_GRAPH_BINARY).`, + ); + } + const project = stringOf(dump.project, "dump.project"); + if (!samePath(project, expectedRoot)) { + throw new Error( + `ttscgraph: response project ${project} does not match ${expectedRoot}`, + ); + } + const target = stringOf(dump.tsconfig, "dump.tsconfig"); + validateGraphFile(target, "dump.tsconfig"); + const capabilities = stringArrayOf( + rawProvenance.capabilities, + "dump.provenance.capabilities", + ); + const manifest = manifestOf(dump.provenance); + mergeConfigurationSources(manifest, dump.provenance, capabilities); + const sources = new Map(); + // Preserve the complete compiler-owned manifest. Relative identities become + // absolute keys for the bulk-session contract; identities that are already + // absolute stay canonical, and bundled virtual identities must never pass + // through `path.resolve`, which would turn them into unrelated disk paths. + for (const [file, digest] of [...manifest].sort(([left], [right]) => + compareOrdinal(left, right), + )) { + sources.set(sourceManifestKey(expectedRoot, file), digest); + } + + const context: ITtscGraphDumpContext = { + target, + capabilities, + manifest, sources, provenance: provenanceOf( dump.provenance, schemaVersion as number, capabilities, + target, ), - warnings, + warnings: [], + adapt: (facts) => adaptTtscGraphDumpWithContext(facts, context), }; + return context; } /** @@ -352,7 +398,7 @@ function manifestOf( * would fingerprint identically, and a universe change that reshuffled exactly * that way would look like no change at all. */ -function universeOf(value: unknown): string { +function universeOf(value: unknown, target: string): string { const universe = objectOf(value, "dump.provenance.universe"); const hash = createHash("sha256"); const push = (text: string): void => { @@ -388,6 +434,11 @@ function universeOf(value: unknown): string { ), ); } + if (!configFiles.has(target)) { + throw new Error( + `ttscgraph: dump.tsconfig names an unknown build-universe config: ${target}`, + ); + } const roots = arrayOf(universe.roots, "dump.provenance.universe.roots"); push("roots"); const rootsByConfig = new Map>(); @@ -421,6 +472,7 @@ function provenanceOf( value: unknown, schemaVersion: number, capabilities: string[], + target: string, ): Omit { const provenance = objectOf(value, "dump.provenance"); // Read the universe even though only the fingerprint is kept: skipping the @@ -448,8 +500,8 @@ function provenanceOf( producer.typescript, "dump.provenance.producer.typescript", ), - universe: universeOf(provenance.universe), - capabilities, + universe: universeOf(provenance.universe, target), + capabilities: [...new Set(capabilities)].sort(compareOrdinal), }; } @@ -548,6 +600,9 @@ const NODE_KINDS = new Set([ * cannot run: the function declaration above it is always evaluated first. * The constants inside run unconditionally, so nothing testable is hidden. */ export namespace adaptTtscGraphDump { + /** Reuse generation-wide validation while adapting native shard deltas. */ + export const prepareContext = prepareTtscGraphDumpContext; + /** The registry identity every `ttscgraph` snapshot is published under. */ export const PROVIDER = "ttscgraph"; @@ -923,11 +978,28 @@ function validateNodeId(id: string, file: string, kind: GraphNodeKind): void { } function samePath(left: string, right: string): boolean { - const normalizedLeft = path.resolve(left); - const normalizedRight = path.resolve(right); + const normalizedLeft = physicalPath(left); + const normalizedRight = physicalPath(right); // Only one arm of this comparison runs on a given operating system. /* c8 ignore next 3 */ return process.platform === "win32" ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() : normalizedLeft === normalizedRight; } + +/** Resolve aliases through the longest existing ancestor, including missing leaves. */ +function physicalPath(location: string): string { + let candidate = path.resolve(location); + const suffix: string[] = []; + for (;;) { + try { + return path.join(fs.realpathSync.native(candidate), ...suffix.reverse()); + } catch { + const parent = path.dirname(candidate); + /* c8 ignore next -- every supported platform has an existing filesystem root. */ + if (parent === candidate) return path.resolve(location); + suffix.push(path.basename(candidate)); + candidate = parent; + } + } +} diff --git a/packages/graph/src/provider/ttscgraph/createTtscGraphProtocolTransaction.ts b/packages/graph/src/provider/ttscgraph/createTtscGraphProtocolTransaction.ts new file mode 100644 index 00000000..e02aa229 --- /dev/null +++ b/packages/graph/src/provider/ttscgraph/createTtscGraphProtocolTransaction.ts @@ -0,0 +1,311 @@ +import path from "node:path"; + +import { ISamchonGraphCoverage } from "../../structures"; +import { GRAPH_EDGE_KINDS } from "../../typings"; +import { GraphSnapshotProtocol } from "../GraphSnapshotProtocol"; +import { IBulkGraphSession } from "../IBulkGraphSession"; +import { adaptTtscGraphDump } from "./adaptTtscGraphDump"; + +type IAdaptedTtscGraphDump = ReturnType; + +/** + * Normalize one complete compiler dump into a Graph Snapshot Protocol + * transaction. The native process still owns semantic incrementality; this + * adapter adds content-addressed file shards so the graph store can reuse every + * unchanged part of the last committed generation. + */ +export function createTtscGraphProtocolTransaction( + input: IAdaptedTtscGraphDump, + options: { + root: string; + sequence: number; + previous?: IBulkGraphSession.ISnapshot; + }, +): GraphSnapshotProtocol.Frame[] { + const hello: GraphSnapshotProtocol.IHello = { + type: "hello", + protocolVersion: GraphSnapshotProtocol.VERSION, + schemaVersion: GraphSnapshotProtocol.SCHEMA_VERSION, + producerSchemaVersion: input.provenance.schemaVersion, + provider: input.provenance.provider, + producer: input.provenance.tool, + producerVersion: input.provenance.toolVersion, + compilerVersion: input.provenance.compilerVersion, + languages: ["typescript"], + authority: input.provenance.authority, + supportedFacts: [...input.provenance.facts], + capabilities: [...input.provenance.capabilities], + }; + const sources = [...input.sources].map(([file, digest]) => ({ + file, + checkerDigest: digest.checkerDigest, + diskDigest: digest.diskDigest, + })); + const manifest = GraphSnapshotProtocol.manifestDigest(sources); + const shards = shardTtscGraph(input, options.root, sources); + const ordered = [...shards].sort(([left], [right]) => + compareText(left, right), + ); + const shardManifest = ordered.map(([key, shard]) => ({ + key, + digest: GraphSnapshotProtocol.shardDigest(shard), + })); + const previous = options.previous; + const canReuse = + previous?.protocol !== undefined && + previous.provenance.universe === input.provenance.universe && + sameList(previous.protocol.targets, [input.target]) && + sameProducer(previous.provenance, input.provenance); + const begin: GraphSnapshotProtocol.IBegin = { + type: "begin", + sequence: options.sequence, + generation: "", + ...(canReuse + ? { + baseSequence: previous.protocol!.sequence, + baseGeneration: previous.protocol!.generation, + } + : {}), + universe: input.provenance.universe, + manifest, + targets: [input.target], + }; + const snapshot = assembledSnapshot(hello, begin, ordered); + const factDigest = GraphSnapshotProtocol.factDigest(snapshot); + begin.generation = factDigest; + + const previousShards = new Map( + canReuse + ? previous.protocol!.shards.map((entry) => [entry.key, entry.digest]) + : [], + ); + const nextKeys = new Set(shards.keys()); + const deltas: GraphSnapshotProtocol.Frame[] = []; + if (canReuse) { + for (const key of previousShards.keys()) { + if (!nextKeys.has(key)) deltas.push({ type: "deleteShard", key }); + } + } + for (const [key, shard] of ordered) { + const digest = GraphSnapshotProtocol.shardDigest(shard); + if (!canReuse || previousShards.get(key) !== digest) { + deltas.push({ type: "upsertShard", digest, shard }); + } + } + return [ + hello, + begin, + ...deltas, + { + type: "commit", + sequence: begin.sequence, + generation: begin.generation, + shards: shardManifest, + factDigest, + }, + ]; +} + +function shardTtscGraph( + input: IAdaptedTtscGraphDump, + root: string, + sources: readonly GraphSnapshotProtocol.ISource[], +): Map { + const output = new Map(); + const sourceShardByFile = new Map(); + const sourceFileByNode = new Map( + input.nodes.map((node) => [node.id, sourceFile(root, node.file)]), + ); + for (const source of sources) { + const key = sourceShardKey(input, root, source); + const shard: GraphSnapshotProtocol.IShard = { + key, + target: input.target, + languages: ["typescript"], + nodes: [], + edges: [], + diagnostics: [], + coverage: [], + unresolved: [], + sources: [{ ...source }], + }; + output.set(key, shard); + sourceShardByFile.set(source.file, shard); + } + + for (const node of input.nodes) { + sourceShard(sourceShardByFile, sourceFile(root, node.file)).nodes.push(node); + } + for (const edge of input.edges) { + // adaptTtscGraphDump already proved every edge source is a published node. + const file = sourceFileByNode.get(edge.from)!; + sourceShard(sourceShardByFile, file).edges.push(edge); + } + + const coverageShard: GraphSnapshotProtocol.IShard = { + key: metadataShardKey(input), + target: input.target, + languages: ["typescript"], + nodes: [], + edges: [], + diagnostics: [], + coverage: coverageOf(input), + unresolved: unresolvedOf(input), + sources: [], + }; + for (const diagnostic of input.diagnostics) { + if (diagnostic.file === "") coverageShard.diagnostics.push(diagnostic); + else + sourceShard( + sourceShardByFile, + sourceFile(root, diagnostic.file), + ).diagnostics.push(diagnostic); + } + output.set(coverageShard.key, coverageShard); + return output; +} + +function coverageOf( + input: IAdaptedTtscGraphDump, +): ISamchonGraphCoverage[] { + const supported = new Set(input.provenance.facts); + return GRAPH_EDGE_KINDS.map((family) => ({ + provider: input.provenance.provider, + language: "typescript", + target: input.target, + family, + state: supported.has(family) ? "partial" : "unsupported", + })); +} + +function unresolvedOf( + input: IAdaptedTtscGraphDump, +): GraphSnapshotProtocol.IShard["unresolved"] { + return input.provenance.facts.map((family) => ({ + provider: input.provenance.provider, + language: "typescript", + target: input.target, + universe: input.provenance.universe, + family, + evidence: { + file: input.target, + startLine: 1, + startCol: 1, + }, + reason: "provider-gap", + })); +} + +function assembledSnapshot( + hello: GraphSnapshotProtocol.IHello, + begin: GraphSnapshotProtocol.IBegin, + shards: readonly [string, GraphSnapshotProtocol.IShard][], +): Pick< + IBulkGraphSession.ISnapshot, + | "languages" + | "nodes" + | "edges" + | "diagnostics" + | "coverage" + | "unresolved" + | "provenance" +> { + const values = shards.map(([, shard]) => shard); + return { + languages: [...hello.languages], + nodes: values.flatMap((shard) => shard.nodes), + edges: values.flatMap((shard) => shard.edges), + diagnostics: values.flatMap((shard) => shard.diagnostics), + coverage: values.flatMap((shard) => shard.coverage), + unresolved: values.flatMap((shard) => shard.unresolved), + provenance: { + provider: hello.provider, + authority: hello.authority, + facts: [...hello.supportedFacts], + schemaVersion: hello.producerSchemaVersion, + tool: hello.producer, + toolVersion: hello.producerVersion, + compilerVersion: hello.compilerVersion, + protocolVersion: hello.protocolVersion, + universe: begin.universe, + capabilities: [...hello.capabilities], + }, + }; +} + +function sourceShard( + shards: ReadonlyMap, + file: string, +): GraphSnapshotProtocol.IShard { + // adaptTtscGraphDump already bound every fact file to this exact manifest. + return shards.get(file)!; +} + +function sourceFile(root: string, file: string): string { + return file.startsWith("bundled:///") ? file : path.resolve(root, file); +} + +function sourceShardKey( + input: IAdaptedTtscGraphDump, + root: string, + source: GraphSnapshotProtocol.ISource, +): string { + const bundled = source.file.startsWith("bundled:///"); + const identity = bundled + ? source.file + : path.relative(root, source.file).replaceAll("\\", "/"); + return `${bundled ? "2" : "1"}:source:${JSON.stringify([ + GraphSnapshotProtocol.VERSION, + input.provenance.provider, + input.provenance.toolVersion, + input.provenance.compilerVersion, + "typescript", + input.target, + input.provenance.universe, + identity, + source.checkerDigest, + ])}`; +} + +function metadataShardKey(input: IAdaptedTtscGraphDump): string { + return `0:coverage:${JSON.stringify([ + GraphSnapshotProtocol.VERSION, + input.provenance.provider, + input.provenance.toolVersion, + input.provenance.compilerVersion, + "typescript", + input.target, + input.provenance.universe, + ])}`; +} + +function sameProducer( + left: IBulkGraphSession.IProvenance, + right: Omit, +): boolean { + return ( + left.provider === right.provider && + left.authority === right.authority && + left.schemaVersion === right.schemaVersion && + left.tool === right.tool && + left.toolVersion === right.toolVersion && + left.compilerVersion === right.compilerVersion && + sameList(left.facts, right.facts) && + sameList(left.capabilities, right.capabilities) + ); +} + +function sameList( + left: readonly string[], + right: readonly string[], +): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +function compareText(left: string, right: string): number { + /* c8 ignore next -- shard keys are unique. */ + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/graph/src/provider/ttscgraph/parseTtscGraphSnapshot.ts b/packages/graph/src/provider/ttscgraph/parseTtscGraphSnapshot.ts index 118d5fb0..6a86d59b 100644 --- a/packages/graph/src/provider/ttscgraph/parseTtscGraphSnapshot.ts +++ b/packages/graph/src/provider/ttscgraph/parseTtscGraphSnapshot.ts @@ -92,8 +92,10 @@ export function parseTtscGraphSnapshot(value: unknown): ITtscGraphSnapshot { "ttscgraph: error response cannot also report a changed graph", ); } - if (raw.dump !== undefined) { - throw new Error("ttscgraph: error response unexpectedly included a dump"); + if (raw.dump !== undefined || raw.snapshot !== undefined) { + throw new Error( + "ttscgraph: error response unexpectedly included snapshot state", + ); } return { ...base, mode: "error", error: raw.error, changed: false }; } @@ -104,21 +106,28 @@ export function parseTtscGraphSnapshot(value: unknown): ITtscGraphSnapshot { ); } - // `changed` decides whether a dump rides along; the producer stakes its whole - // atomicity claim on that pairing, so a frame that breaks it is rejected here - // rather than surfacing later as an absent dump nobody expected. - if (raw.changed && raw.dump === undefined) { - throw new Error(`ttscgraph: changed ${mode} response omitted its full dump`); + // `changed` decides whether a shard transaction rides along; the producer + // stakes its whole atomicity claim on that pairing, so a frame that breaks it + // is rejected here rather than surfacing later as absent state. + if (raw.dump !== undefined) { + throw new Error( + "ttscgraph: binary returned a legacy full dump instead of graph snapshot protocol v1; install a matching ttsc", + ); + } + if (raw.changed && raw.snapshot === undefined) { + throw new Error( + `ttscgraph: changed ${mode} response omitted its native shard transaction`, + ); } - if (!raw.changed && raw.dump !== undefined) { + if (!raw.changed && raw.snapshot !== undefined) { throw new Error( - `ttscgraph: unchanged ${mode} response unexpectedly included a dump`, + `ttscgraph: unchanged ${mode} response unexpectedly included a native shard transaction`, ); } return { ...base, mode: mode as ITtscGraphSnapshot.ComputationMode, changed: raw.changed, - ...(raw.dump === undefined ? {} : { dump: raw.dump }), + ...(raw.snapshot === undefined ? {} : { snapshot: raw.snapshot }), }; } diff --git a/packages/graph/src/provider/ttscgraph/resolveTtscGraphCommand.ts b/packages/graph/src/provider/ttscgraph/resolveTtscGraphCommand.ts index bfbf20fa..c4a89dee 100644 --- a/packages/graph/src/provider/ttscgraph/resolveTtscGraphCommand.ts +++ b/packages/graph/src/provider/ttscgraph/resolveTtscGraphCommand.ts @@ -5,6 +5,13 @@ import path from "node:path"; import { isSpawnableFile } from "../../utils/isSpawnableFile"; import { spawnableCommand } from "../../utils/spawnableCommand"; +import { ttscGraphResolution } from "./ttscGraphResolution"; + +const [TTSC_GRAPH_COMMAND, TTSC_SERVER_COMMAND] = + ttscGraphResolution.commands; +const [TTSC_GRAPH_OVERRIDE] = + ttscGraphResolution.environmentOverrides; + interface ITtscGraphCommand { command: string; args: string[]; @@ -15,7 +22,7 @@ export function resolveTtscGraphCommand( root: string, env: NodeJS.ProcessEnv = process.env, ): ITtscGraphCommand | undefined { - const override = env.TTSC_GRAPH_BINARY; + const override = env[TTSC_GRAPH_OVERRIDE]; if ( override !== undefined && path.isAbsolute(override) && @@ -36,7 +43,12 @@ export function resolveTtscGraphCommand( // A package-manager shim can still reveal the project installation when its // package metadata is not directly resolvable (for example, an unusual // linked layout). Search only the target project's .bin at this stage. - const projectServer = resolveExecutable("ttscserver", root, env, false); + const projectServer = resolveExecutable( + TTSC_SERVER_COMMAND, + root, + env, + false, + ); if (projectServer !== undefined) { const beside = graphBesideServer(projectServer); if (beside !== undefined) return beside; @@ -44,10 +56,15 @@ export function resolveTtscGraphCommand( // Only after project-owned candidates fail may PATH/global installations be // used as a compatibility fallback. - const onPath = resolveExecutable("ttscgraph", root, env, true); + const onPath = resolveExecutable(TTSC_GRAPH_COMMAND, root, env, true); if (onPath !== undefined) return spawnable(onPath); - const globalServer = resolveExecutable("ttscserver", root, env, true); + const globalServer = resolveExecutable( + TTSC_SERVER_COMMAND, + root, + env, + true, + ); if (globalServer !== undefined && globalServer !== projectServer) { return graphBesideServer(globalServer); } diff --git a/packages/graph/src/provider/ttscgraph/ttscGraphPhaseTrace.ts b/packages/graph/src/provider/ttscgraph/ttscGraphPhaseTrace.ts new file mode 100644 index 00000000..2352bc27 --- /dev/null +++ b/packages/graph/src/provider/ttscgraph/ttscGraphPhaseTrace.ts @@ -0,0 +1,61 @@ +import fs from "node:fs"; + +const PHASE_TRACE_ENVIRONMENT = "SAMCHON_GRAPH_TTSC_PHASE_TRACE"; +const PREFIX = "@samchon/graph: ttscgraph-phase "; +const PRODUCER_LINE = + /^@samchon\/graph: ttscgraph-phase owner=producer request=[1-9]\d* mode=(?:initial|reload|unchanged|incremental|rebuild|error) phase=(?:native-load|semantic-refresh|shard-export|encode|producer-total) durationMs=\d+\.\d{3}$/u; + +/** Opt-in, payload-free timing trace for the native TypeScript graph route. */ +export function ttscGraphPhaseTrace( + env: NodeJS.ProcessEnv = process.env, + write: (line: string) => unknown = (line) => + typeof process.stderr.fd === "number" + ? fs.writeSync(process.stderr.fd, line) + : process.stderr.write(line), +): ttscGraphPhaseTrace.ITrace | undefined { + if (env[PHASE_TRACE_ENVIRONMENT] !== "1") return undefined; + const emit = (line: string): void => { + try { + write(line); + } catch { + // Observability must never alter provider transport or publication. + } + }; + return { + event: (event) => { + emit( + `${PREFIX}owner=consumer request=${String(event.request)}` + + ` mode=${event.mode} phase=${event.phase}` + + ` durationMs=${event.durationMs.toFixed(3)}\n`, + ); + }, + forwardProducer: (buffer, chunk) => { + const joined = buffer + chunk; + const lines = joined.split("\n"); + const remainder = lines.pop()!; + for (const raw of lines) { + const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw; + if (PRODUCER_LINE.test(line)) emit(`${line}\n`); + } + return remainder.length <= 4_096 ? remainder : remainder.slice(-4_096); + }, + }; +} + +export namespace ttscGraphPhaseTrace { + export interface IEvent { + request: number; + mode: string; + phase: + | "producer-roundtrip" + | "native-normalize" + | "common-commit" + | "mcp-ready"; + durationMs: number; + } + + export interface ITrace { + event: (event: IEvent) => void; + forwardProducer: (buffer: string, chunk: string) => string; + } +} diff --git a/packages/graph/src/provider/ttscgraph/ttscGraphProvider.ts b/packages/graph/src/provider/ttscgraph/ttscGraphProvider.ts index b26d4f9f..f9621ce5 100644 --- a/packages/graph/src/provider/ttscgraph/ttscGraphProvider.ts +++ b/packages/graph/src/provider/ttscgraph/ttscGraphProvider.ts @@ -2,6 +2,7 @@ import { IGraphProvider } from "../IGraphProvider"; import { assertGraphSnapshotContract } from "../assertGraphSnapshotContract"; import { adaptTtscGraphDump } from "./adaptTtscGraphDump"; import { resolveTtscGraphCommand } from "./resolveTtscGraphCommand"; +import { ttscGraphResolution } from "./ttscGraphResolution"; import { TtscGraphClient } from "./TtscGraphClient"; import { ttscGraphStrictRefusal } from "./ttscGraphStrictRefusal"; @@ -24,6 +25,7 @@ export const ttscGraphProvider: IGraphProvider = { authority: "compiler", facts: adaptTtscGraphDump.EDGE_KINDS, + resolution: ttscGraphResolution, // A `tsconfig` change can add or drop whole files from the program, and a // `package.json` change can move the resolution roots those files import diff --git a/packages/graph/src/provider/ttscgraph/ttscGraphResolution.ts b/packages/graph/src/provider/ttscgraph/ttscGraphResolution.ts new file mode 100644 index 00000000..51c447d6 --- /dev/null +++ b/packages/graph/src/provider/ttscgraph/ttscGraphResolution.ts @@ -0,0 +1,6 @@ +import { IGraphProvider } from "../IGraphProvider"; + +export const ttscGraphResolution = Object.freeze({ + commands: Object.freeze(["ttscgraph", "ttscserver"] as const), + environmentOverrides: Object.freeze(["TTSC_GRAPH_BINARY"] as const), +}) satisfies IGraphProvider.IResolution; diff --git a/packages/graph/src/provider/ttscgraph/ttscGraphStrictRefusal.ts b/packages/graph/src/provider/ttscgraph/ttscGraphStrictRefusal.ts index 73fcd728..e5f8a9ff 100644 --- a/packages/graph/src/provider/ttscgraph/ttscGraphStrictRefusal.ts +++ b/packages/graph/src/provider/ttscgraph/ttscGraphStrictRefusal.ts @@ -43,7 +43,7 @@ export function ttscGraphStrictRefusal( // nothing — the reader needs the whole reason, not the first clause of it. return ( `typescript: ttscgraph bulk indexing is disabled by ${refused.join(", ")}; ` + - `the compiler-owned provider publishes whole-program snapshots and has no bounded mode, ` + + `the compiler-owned provider publishes whole-program generations and has no bounded mode, ` + `so this language falls through to the generic ttscserver LSP lane (and static fallback if that lane cannot answer). ` + `These facts are not compiler-owned. Drop ${ refused.length === 1 ? "that option" : "those options" diff --git a/packages/graph/src/repository/IRepositoryContextProvider.ts b/packages/graph/src/repository/IRepositoryContextProvider.ts new file mode 100644 index 00000000..00277e45 --- /dev/null +++ b/packages/graph/src/repository/IRepositoryContextProvider.ts @@ -0,0 +1,43 @@ +import { + RepositoryContextAuthority, + RepositoryContextRelationKind, +} from "../typings"; +import { IRepositoryContextSession } from "./IRepositoryContextSession"; +import { RepositoryContextProtocol } from "./RepositoryContextProtocol"; + +/** One sibling repository-topology provider. */ +export interface IRepositoryContextProvider { + readonly name: string; + readonly ecosystem: string; + readonly authority: Exclude; + readonly families: readonly RepositoryContextRelationKind[]; + readonly buildInputs: readonly string[]; + + /** Whether this repository declares the ecosystem. */ + detect(root: string): boolean; + + /** Open a resident topology session without changing the project. */ + open(props: IRepositoryContextProvider.IOpenProps): IRepositoryContextSession; +} + +export namespace IRepositoryContextProvider { + export interface IOpenProps { + root: string; + env: NodeJS.ProcessEnv; + } + + export interface ICollection { + producerSchemaVersion: number; + tool: string; + toolVersion: string; + capabilities: string[]; + universe: string; + target: string; + shards: RepositoryContextProtocol.IShard[]; + warnings: string[]; + } + + export type Collector = ( + props: IOpenProps & { signal?: AbortSignal }, + ) => Promise | ICollection; +} diff --git a/packages/graph/src/repository/IRepositoryContextSession.ts b/packages/graph/src/repository/IRepositoryContextSession.ts new file mode 100644 index 00000000..d8337d16 --- /dev/null +++ b/packages/graph/src/repository/IRepositoryContextSession.ts @@ -0,0 +1,26 @@ +import { RepositoryContextProtocol } from "./RepositoryContextProtocol"; + +/** One resident repository-context provider session. */ +export interface IRepositoryContextSession { + readonly kind: "repository-context"; + readonly provider: string; + readonly ecosystem: string; + readonly root: string; + readonly generation: number; + readonly current: RepositoryContextProtocol.ISnapshot | undefined; + + refresh(options?: { + signal?: AbortSignal; + }): Promise; + close(): Promise; +} + +export namespace IRepositoryContextSession { + export interface IRefresh { + changed: boolean; + generation: number; + mode: "initial" | "unchanged" | "incremental" | "reload"; + snapshot: RepositoryContextProtocol.ISnapshot; + warnings: string[]; + } +} diff --git a/packages/graph/src/repository/IResidentRepositoryContextSource.ts b/packages/graph/src/repository/IResidentRepositoryContextSource.ts new file mode 100644 index 00000000..e11ca6fb --- /dev/null +++ b/packages/graph/src/repository/IResidentRepositoryContextSource.ts @@ -0,0 +1,9 @@ +import { ISamchonRepositoryContextDump } from "../structures"; + +/** Resident sibling source for repository topology. */ +export interface IResidentRepositoryContextSource { + load(options?: { + signal?: AbortSignal; + }): Promise; + close(): Promise; +} diff --git a/packages/graph/src/repository/REPOSITORY_CONTEXT_PROVIDERS.ts b/packages/graph/src/repository/REPOSITORY_CONTEXT_PROVIDERS.ts new file mode 100644 index 00000000..0cbb8719 --- /dev/null +++ b/packages/graph/src/repository/REPOSITORY_CONTEXT_PROVIDERS.ts @@ -0,0 +1,14 @@ +import { cargoRepositoryContextProvider } from "./cargoRepositoryContextProvider"; +import { cmakeRepositoryContextProvider } from "./cmakeRepositoryContextProvider"; +import { gradleRepositoryContextProvider } from "./gradleRepositoryContextProvider"; +import { pnpmRepositoryContextProvider } from "./pnpmRepositoryContextProvider"; +import { validateRepositoryContextProviders } from "./validateRepositoryContextProviders"; + +/** Built-in sibling repository-context provider registry. */ +export const REPOSITORY_CONTEXT_PROVIDERS = + validateRepositoryContextProviders([ + pnpmRepositoryContextProvider, + cargoRepositoryContextProvider, + gradleRepositoryContextProvider, + cmakeRepositoryContextProvider, + ]); diff --git a/packages/graph/src/repository/RepositoryContextProtocol.ts b/packages/graph/src/repository/RepositoryContextProtocol.ts new file mode 100644 index 00000000..3d75f2a3 --- /dev/null +++ b/packages/graph/src/repository/RepositoryContextProtocol.ts @@ -0,0 +1,725 @@ +import { createHash } from "node:crypto"; + +import { ISamchonRepositoryContextDump } from "../structures"; +import { + RepositoryContextAuthority, + RepositoryContextCoverageState, + RepositoryContextNodeKind, + RepositoryContextRelationKind, +} from "../typings"; + +/** Atomic, content-addressed repository-context shard protocol. */ +export namespace RepositoryContextProtocol { + export const VERSION = 1 as const; + export const SCHEMA_VERSION = 1 as const; + + export const RELATION_KINDS = [ + "contains", + "depends-on", + "source-of", + "test-of", + "produces", + "invokes", + "entrypoint-of", + "joins-file", + ] as const satisfies readonly RepositoryContextRelationKind[]; + + const AUTHORITIES = [ + "tool-resolved", + "declared", + "inferred", + ] as const satisfies readonly RepositoryContextAuthority[]; + + const COVERAGE_STATES = [ + "complete", + "partial", + "unsupported", + ] as const satisfies readonly RepositoryContextCoverageState[]; + + const NODE_KINDS = [ + "workspace", + "project", + "package", + "source-set", + "source-root", + "generated-root", + "build-target", + "task", + "entrypoint", + ] as const satisfies readonly RepositoryContextNodeKind[]; + + export interface IHello { + type: "hello"; + protocolVersion: 1; + schemaVersion: 1; + producerSchemaVersion: number; + provider: string; + ecosystem: string; + authority: RepositoryContextAuthority; + tool: string; + toolVersion: string; + supportedFamilies: RepositoryContextRelationKind[]; + capabilities: string[]; + } + + export interface IBegin { + type: "begin"; + sequence: number; + generation: string; + baseSequence?: number; + baseGeneration?: string; + inputGeneration: string; + universe: string; + target: string; + manifest: string; + } + + export interface IShard { + key: string; + target: string; + nodes: ISamchonRepositoryContextDump.INode[]; + edges: ISamchonRepositoryContextDump.IEdge[]; + coverage: ISamchonRepositoryContextDump.ICoverage[]; + files: string[]; + sources: ISamchonRepositoryContextDump.ISource[]; + } + + export interface IUpsertShard { + type: "upsertShard"; + digest: string; + shard: IShard; + } + + export interface IDeleteShard { + type: "deleteShard"; + key: string; + } + + export interface ICommit { + type: "commit"; + sequence: number; + generation: string; + shards: ISamchonRepositoryContextDump.IShard[]; + contentDigest: string; + } + + export type Frame = + | IHello + | IBegin + | IUpsertShard + | IDeleteShard + | ICommit; + + export interface ISnapshot { + hello: IHello; + begin: IBegin; + generation: ISamchonRepositoryContextDump.IGeneration; + nodes: ISamchonRepositoryContextDump.INode[]; + edges: ISamchonRepositoryContextDump.IEdge[]; + coverage: ISamchonRepositoryContextDump.ICoverage[]; + files: string[]; + sources: ISamchonRepositoryContextDump.ISource[]; + } + + /** SHA-256 over a canonical JSON value. */ + export function digest(value: unknown): string { + return createHash("sha256").update(canonical(value)).digest("hex"); + } + + export function shardDigest(shard: IShard): string { + return digest(normalizeShard(shard)); + } + + export function manifestDigest( + sources: readonly ISamchonRepositoryContextDump.ISource[], + ): string { + const unique = new Map(); + for (const source of sources) { + const prior = unique.get(source.file); + if (prior !== undefined && prior !== source.digest) { + throw new Error( + `repository context protocol: sources disagree about ${source.file}`, + ); + } + unique.set(source.file, source.digest); + } + return digest( + [...unique] + .sort(([left], [right]) => compare(left, right)) + .map(([file, sourceDigest]) => ({ file, digest: sourceDigest })), + ); + } + + export function contentDigest( + snapshot: Pick, + ): string { + return digest({ + nodes: [...snapshot.nodes].sort((left, right) => + compare(left.id, right.id), + ), + edges: [...snapshot.edges].sort(compareEdges), + coverage: [...snapshot.coverage].sort(compareCoverage), + }); + } + + /** One-provider atomic shard store. */ + export class Store { + private committed = new Map(); + private identity: IHello | undefined; + private snapshot: ISnapshot | undefined; + + public get current(): ISnapshot | undefined { + return this.snapshot; + } + + public apply( + frames: readonly Frame[], + options: { signal?: AbortSignal } = {}, + ): ISnapshot { + throwIfAborted(options.signal); + if (frames.length < 3) { + throw new Error("repository context protocol: incomplete transaction"); + } + const hello = frames[0]; + const begin = frames[1]; + const commit = frames.at(-1); + if (hello?.type !== "hello" || begin?.type !== "begin") { + throw new Error( + "repository context protocol: transaction must start with hello and begin", + ); + } + if (commit?.type !== "commit") { + throw new Error( + "repository context protocol: transaction must end with commit", + ); + } + assertHello(hello); + assertBegin(begin); + if ( + commit.sequence !== begin.sequence || + commit.generation !== begin.generation + ) { + throw new Error( + "repository context protocol: commit generation does not match begin", + ); + } + if (this.identity !== undefined && !sameIdentity(this.identity, hello)) { + throw new Error( + "repository context protocol: provider identity changed inside one store", + ); + } + const prior = this.snapshot?.begin; + if (prior === undefined) { + if ( + begin.sequence !== 1 || + begin.baseSequence !== undefined || + begin.baseGeneration !== undefined + ) { + throw new Error( + "repository context protocol: initial generation must start at sequence 1 without a base", + ); + } + } else if ( + begin.sequence !== prior.sequence + 1 || + begin.baseSequence !== prior.sequence || + begin.baseGeneration !== prior.generation + ) { + throw new Error( + "repository context protocol: delta does not extend the current generation", + ); + } + + const next = + prior === undefined + ? new Map() + : new Map(this.committed); + const touched = new Set(); + for (const frame of frames.slice(2, -1)) { + throwIfAborted(options.signal); + if (frame.type === "upsertShard") { + if (touched.has(frame.shard.key)) { + throw new Error( + `repository context protocol: duplicate shard delta ${frame.shard.key}`, + ); + } + assertShard(frame.shard, hello, begin); + const actual = shardDigest(frame.shard); + if (actual !== frame.digest) { + throw new Error( + `repository context protocol: shard digest mismatch ${frame.shard.key}`, + ); + } + touched.add(frame.shard.key); + next.set(frame.shard.key, { + digest: actual, + shard: clone(frame.shard), + }); + } else if (frame.type === "deleteShard") { + if (touched.has(frame.key) || !next.has(frame.key)) { + throw new Error( + `repository context protocol: invalid shard deletion ${frame.key}`, + ); + } + touched.add(frame.key); + next.delete(frame.key); + } else { + throw new Error( + `repository context protocol: unexpected transaction frame ${frame.type}`, + ); + } + } + + const manifest = [...next] + .sort(([left], [right]) => compare(left, right)) + .map(([key, value]) => ({ key, digest: value.digest })); + if (canonical(manifest) !== canonical(commit.shards)) { + throw new Error( + "repository context protocol: commit shard manifest mismatch", + ); + } + const assembled = assemble(hello, begin, manifest, next); + assertSnapshot(assembled); + if (manifestDigest(assembled.sources) !== begin.manifest) { + throw new Error( + "repository context protocol: input manifest digest mismatch", + ); + } + const facts = contentDigest(assembled); + if (facts !== commit.contentDigest) { + throw new Error( + "repository context protocol: content digest mismatch", + ); + } + throwIfAborted(options.signal); + const published: ISnapshot = { + ...assembled, + generation: { + sequence: begin.sequence, + token: begin.generation, + shards: manifest, + contentDigest: facts, + }, + }; + freeze(published); + this.committed = next; + this.identity = clone(hello); + this.snapshot = published; + return published; + } + } + + function assemble( + hello: IHello, + begin: IBegin, + manifest: ISamchonRepositoryContextDump.IShard[], + shards: ReadonlyMap, + ): ISnapshot { + const nodes: ISamchonRepositoryContextDump.INode[] = []; + const edges: ISamchonRepositoryContextDump.IEdge[] = []; + const coverage: ISamchonRepositoryContextDump.ICoverage[] = []; + const files = new Set(); + const sources = new Map(); + for (const entry of manifest) { + const shard = shards.get(entry.key)!.shard; + nodes.push(...clone(shard.nodes)); + edges.push(...clone(shard.edges)); + coverage.push(...clone(shard.coverage)); + for (const file of shard.files) files.add(file); + for (const source of shard.sources) { + const prior = sources.get(source.file); + if (prior !== undefined && prior !== source.digest) { + throw new Error( + `repository context protocol: shards disagree about ${source.file}`, + ); + } + sources.set(source.file, source.digest); + } + } + return { + hello: clone(hello), + begin: clone(begin), + generation: { + sequence: begin.sequence, + token: begin.generation, + shards: manifest, + contentDigest: "", + }, + nodes, + edges, + coverage, + files: [...files].sort(compare), + sources: [...sources] + .sort(([left], [right]) => compare(left, right)) + .map(([file, sourceDigest]) => ({ file, digest: sourceDigest })), + }; + } + + function assertHello(hello: IHello): void { + if ( + hello.protocolVersion !== VERSION || + hello.schemaVersion !== SCHEMA_VERSION || + !Number.isSafeInteger(hello.producerSchemaVersion) || + hello.producerSchemaVersion < 1 + ) { + throw new Error("repository context protocol: unsupported schema"); + } + for (const value of [ + hello.provider, + hello.ecosystem, + hello.tool, + hello.toolVersion, + ]) { + assertText(value, "hello identity"); + } + if (!AUTHORITIES.includes(hello.authority)) { + throw new Error("repository context protocol: unknown authority"); + } + if (hello.authority === "inferred") { + throw new Error( + "repository context protocol: version 1 refuses inferred facts", + ); + } + assertUniqueClosed( + hello.supportedFamilies, + RELATION_KINDS, + "supported family", + ); + assertUniqueText(hello.capabilities, "capability"); + } + + function assertBegin(begin: IBegin): void { + if (!Number.isSafeInteger(begin.sequence) || begin.sequence < 1) { + throw new Error("repository context protocol: invalid sequence"); + } + for (const value of [ + begin.generation, + begin.inputGeneration, + begin.universe, + begin.target, + ]) { + assertText(value, "generation identity"); + } + assertDigest(begin.manifest, "manifest"); + if ( + (begin.baseSequence === undefined) !== + (begin.baseGeneration === undefined) + ) { + throw new Error( + "repository context protocol: base sequence and generation must move together", + ); + } + if ( + begin.baseSequence !== undefined && + (!Number.isSafeInteger(begin.baseSequence) || begin.baseSequence < 1) + ) { + throw new Error("repository context protocol: invalid base sequence"); + } + if (begin.baseGeneration !== undefined) { + assertText(begin.baseGeneration, "base generation"); + } + } + + function assertShard( + shard: IShard, + hello: IHello, + begin: IBegin, + ): void { + assertText(shard.key, "shard key"); + if (shard.target !== begin.target) { + throw new Error( + `repository context protocol: shard target mismatch ${shard.key}`, + ); + } + const nodeIds = new Set(); + for (const node of shard.nodes) { + for (const value of [ + node.id, + node.name, + node.ecosystem, + node.coordinate, + node.configuration, + ]) { + assertText(value, "node identity"); + } + if (node.ecosystem !== hello.ecosystem || nodeIds.has(node.id)) { + throw new Error( + `repository context protocol: invalid node ownership ${node.id}`, + ); + } + if (!NODE_KINDS.includes(node.kind)) { + throw new Error( + `repository context protocol: unknown node kind ${node.kind}`, + ); + } + if (!AUTHORITIES.includes(node.authority)) { + throw new Error( + `repository context protocol: unknown node authority ${node.id}`, + ); + } + if (node.authority === "inferred") { + throw new Error( + `repository context protocol: version 1 refuses inferred node authority ${node.id}`, + ); + } + if (node.root !== undefined) { + assertText(node.root, "node root"); + if ( + node.kind !== "source-root" && + node.kind !== "generated-root" + ) { + throw new Error( + `repository context protocol: non-root node carries root ${node.id}`, + ); + } + } + if (node.file !== undefined) { + assertText(node.file, "node file"); + } + nodeIds.add(node.id); + assertEvidence(node.evidence); + } + const edgeKeys = new Set(); + for (const edge of shard.edges) { + if (!AUTHORITIES.includes(edge.authority)) { + throw new Error( + `repository context protocol: unknown edge authority ${edge.kind}`, + ); + } + if (edge.authority === "inferred") { + throw new Error( + `repository context protocol: version 1 refuses inferred edge authority ${edge.kind}`, + ); + } + if (!hello.supportedFamilies.includes(edge.kind)) { + throw new Error( + `repository context protocol: unadvertised edge family ${edge.kind}`, + ); + } + assertText(edge.from, "edge source"); + assertText(edge.to, "edge target"); + const key = `${edge.kind}\0${edge.from}\0${edge.to}`; + if (edgeKeys.has(key)) { + throw new Error( + `repository context protocol: duplicate edge ${edge.kind}`, + ); + } + edgeKeys.add(key); + assertEvidence(edge.evidence); + } + const coverageKeys = new Set(); + for (const row of shard.coverage) { + if ( + row.provider !== hello.provider || + row.ecosystem !== hello.ecosystem || + row.target !== begin.target || + !RELATION_KINDS.includes(row.family) || + !COVERAGE_STATES.includes(row.state) + ) { + throw new Error( + "repository context protocol: invalid coverage ownership", + ); + } + if (coverageKeys.has(row.family)) { + throw new Error( + `repository context protocol: duplicate coverage ${row.family}`, + ); + } + coverageKeys.add(row.family); + } + for (const family of RELATION_KINDS) { + if (!coverageKeys.has(family)) { + throw new Error( + `repository context protocol: missing coverage ${family}`, + ); + } + } + const sourceFiles = new Set(); + const joinedFiles = new Set(); + for (const file of shard.files) { + assertText(file, "joined file"); + if (joinedFiles.has(file)) { + throw new Error( + `repository context protocol: duplicate joined file ${file}`, + ); + } + joinedFiles.add(file); + } + for (const source of shard.sources) { + assertText(source.file, "source file"); + assertDigest(source.digest, "source"); + if (sourceFiles.has(source.file)) { + throw new Error( + `repository context protocol: duplicate source ${source.file}`, + ); + } + sourceFiles.add(source.file); + } + } + + function assertSnapshot(snapshot: ISnapshot): void { + const nodes = new Set(); + for (const node of snapshot.nodes) { + if (nodes.has(node.id)) { + throw new Error( + `repository context protocol: duplicate assembled node ${node.id}`, + ); + } + nodes.add(node.id); + } + const files = new Set(snapshot.files); + const edges = new Set(); + for (const edge of snapshot.edges) { + const key = `${edge.kind}\0${edge.from}\0${edge.to}`; + if (edges.has(key)) { + throw new Error( + `repository context protocol: duplicate assembled edge ${edge.kind}`, + ); + } + edges.add(key); + if ( + !nodes.has(edge.from) || + (edge.kind === "joins-file" + ? !files.has(edge.to) + : !nodes.has(edge.to)) + ) { + throw new Error( + `repository context protocol: absent edge endpoint ${edge.from} -> ${edge.to}`, + ); + } + } + } + + function normalizeShard(shard: IShard): IShard { + return { + ...clone(shard), + nodes: [...shard.nodes].sort((left, right) => compare(left.id, right.id)), + edges: [...shard.edges].sort(compareEdges), + coverage: [...shard.coverage].sort(compareCoverage), + files: [...shard.files].sort(compare), + sources: [...shard.sources].sort((left, right) => + compare(left.file, right.file), + ), + }; + } + + function compareEdges( + left: ISamchonRepositoryContextDump.IEdge, + right: ISamchonRepositoryContextDump.IEdge, + ): number { + return ( + compare(left.kind, right.kind) || + compare(left.from, right.from) || + compare(left.to, right.to) + ); + } + + function compareCoverage( + left: ISamchonRepositoryContextDump.ICoverage, + right: ISamchonRepositoryContextDump.ICoverage, + ): number { + return ( + compare(left.provider, right.provider) || + compare(left.ecosystem, right.ecosystem) || + compare(left.target, right.target) || + compare(left.family, right.family) + ); + } + + function sameIdentity(left: IHello, right: IHello): boolean { + return canonical(left) === canonical(right); + } + + function assertEvidence( + evidence: ISamchonRepositoryContextDump.IEvidence | undefined, + ): void { + if (evidence === undefined) return; + assertText(evidence.file, "evidence file"); + for (const value of [ + evidence.startLine, + evidence.startColumn, + evidence.endLine, + evidence.endColumn, + ]) { + if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) { + throw new Error("repository context protocol: invalid evidence span"); + } + } + /* c8 ignore start -- V8 attributes an implicit iterator-completion arm to + * this closing line; valid, absent and invalid evidence fields are tested. */ + } + /* c8 ignore stop */ + + function assertText(value: string, label: string): void { + if (value.trim() === "" || value.includes("\0")) { + throw new Error(`repository context protocol: invalid ${label}`); + } + } + + function assertDigest(value: string, label: string): void { + if (!/^[a-f0-9]{64}$/.test(value)) { + throw new Error(`repository context protocol: invalid ${label} digest`); + } + } + + function assertUniqueText(values: readonly string[], label: string): void { + const seen = new Set(); + for (const value of values) { + assertText(value, label); + if (seen.has(value)) { + throw new Error(`repository context protocol: duplicate ${label}`); + } + seen.add(value); + } + } + + function assertUniqueClosed( + values: readonly T[], + allowed: readonly T[], + label: string, + ): void { + assertUniqueText(values, label); + for (const value of values) { + if (!allowed.includes(value)) { + throw new Error(`repository context protocol: unknown ${label}`); + } + } + } + + function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new Error("repository context protocol: transaction cancelled"); + } + } + + function canonical(value: unknown): string { + return JSON.stringify(sortValue(value)); + } + + function sortValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortValue); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => compare(left, right)) + .map(([key, child]) => [key, sortValue(child)]), + ); + } + return value; + } + + function compare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; + } + + function clone(value: T): T { + return structuredClone(value); + } + + function freeze(value: unknown): void { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) { + return; + } + Object.freeze(value); + for (const child of Object.values(value)) freeze(child); + } +} diff --git a/packages/graph/src/repository/SamchonRepositoryContextMemory.ts b/packages/graph/src/repository/SamchonRepositoryContextMemory.ts new file mode 100644 index 00000000..a907cdd1 --- /dev/null +++ b/packages/graph/src/repository/SamchonRepositoryContextMemory.ts @@ -0,0 +1,147 @@ +import { + ISamchonGraphTopology, + ISamchonRepositoryContextDump, +} from "../structures"; +import { RepositoryContextRelationKind } from "../typings"; + +/** Indexed in-memory view of one repository-context snapshot. */ +export class SamchonRepositoryContextMemory { + public readonly dump: ISamchonRepositoryContextDump; + private readonly nodesById: ReadonlyMap< + string, + ISamchonRepositoryContextDump.INode + >; + + public constructor(dump: ISamchonRepositoryContextDump) { + this.dump = dump; + this.nodesById = new Map(dump.nodes.map((node) => [node.id, node])); + } + + public inspect( + request: ISamchonGraphTopology.IRequest, + join: ISamchonGraphTopology.IJoin, + codeFiles: ReadonlySet = new Set(), + ): ISamchonGraphTopology { + const limit = Math.max(1, Math.min(request.limit ?? 100, 500)); + const joinLimit = Math.max( + 1, + Math.min(request.joinLimit ?? 50, 500), + ); + const families = + request.relations === undefined || request.relations.length === 0 + ? undefined + : new Set(request.relations); + const query = request.query?.trim(); + const availableEdges = + join.state === "compatible" + ? withCodeJoins(this.dump.edges, this.dump.nodes, codeFiles) + : this.dump.edges; + const seeds = + query === undefined || query === "" + ? this.dump.nodes + : this.dump.nodes.filter( + (node) => + node.id === query || + node.name === query || + node.coordinate === query, + ); + const boundedSeeds = seeds.slice(0, limit); + const selected = new Set(boundedSeeds.map((node) => node.id)); + const matchingEdges = availableEdges.filter( + (edge) => + (families === undefined || families.has(edge.kind)) && + (edge.kind !== "joins-file" || + (join.state === "compatible" && codeFiles.has(edge.to))) && + (selected.has(edge.from) || + (edge.kind !== "joins-file" && selected.has(edge.to))), + ); + const matchingJoins = matchingEdges.filter( + (edge) => edge.kind === "joins-file", + ); + const edges = [ + ...matchingEdges.filter((edge) => edge.kind !== "joins-file"), + ...matchingJoins.slice(0, joinLimit), + ]; + for (const edge of edges) { + if (this.nodesById.has(edge.from)) selected.add(edge.from); + if (this.nodesById.has(edge.to)) selected.add(edge.to); + } + const seedIds = new Set(boundedSeeds.map((node) => node.id)); + const nodes = [ + ...boundedSeeds, + ...this.dump.nodes.filter( + (node) => selected.has(node.id) && !seedIds.has(node.id), + ), + ].slice(0, limit); + const retained = new Set(nodes.map((node) => node.id)); + const retainedEdges = edges.filter( + (edge) => + retained.has(edge.from) && + (edge.kind === "joins-file" || retained.has(edge.to)), + ); + return { + type: "topology", + schemaVersion: 1, + nodes, + edges: retainedEdges, + provenance: this.dump.provenance.map((row) => ({ ...row })), + coverage: this.dump.coverage + .filter((row) => families === undefined || families.has(row.family)) + .map((row) => ({ ...row })), + generation: { + ...this.dump.generation, + shards: this.dump.generation.shards.map((row) => ({ ...row })), + }, + join, + truncated: + seeds.length > limit || + matchingJoins.length > joinLimit || + retainedEdges.length < edges.length, + }; + } +} + +function withCodeJoins( + declared: readonly ISamchonRepositoryContextDump.IEdge[], + nodes: readonly ISamchonRepositoryContextDump.INode[], + codeFiles: ReadonlySet, +): ISamchonRepositoryContextDump.IEdge[] { + const rows = new Map( + declared.map( + (edge) => + [`${edge.kind}\0${edge.from}\0${edge.to}`, edge] as const, + ), + ); + for (const node of nodes) { + if (node.file !== undefined && codeFiles.has(node.file)) { + add(node.id, node.file, node.authority); + } + if (node.root !== undefined) { + const prefix = node.root === "." ? "" : `${node.root.replace(/\/$/, "")}/`; + for (const file of codeFiles) { + if (prefix === "" || file.startsWith(prefix)) { + add(node.id, file, node.authority); + } + } + } + } + return [...rows.values()].sort( + (left, right) => + compare(left.kind, right.kind) || + compare(left.from, right.from) || + compare(left.to, right.to), + ); + + function add( + from: string, + to: string, + authority: ISamchonRepositoryContextDump.IEdge["authority"], + ): void { + const edge = { authority, kind: "joins-file" as const, from, to }; + rows.set(`${edge.kind}\0${edge.from}\0${edge.to}`, edge); + } +} + +function compare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/graph/src/repository/cargoRepositoryContextProvider.ts b/packages/graph/src/repository/cargoRepositoryContextProvider.ts new file mode 100644 index 00000000..c4bed4d4 --- /dev/null +++ b/packages/graph/src/repository/cargoRepositoryContextProvider.ts @@ -0,0 +1,460 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import { ISamchonRepositoryContextDump } from "../structures"; +import { spawnableCommand } from "../utils/spawnableCommand"; +import { IRepositoryContextProvider } from "./IRepositoryContextProvider"; +import { createRepositoryContextSession } from "./createRepositoryContextSession"; +import { repositoryContextFacts } from "./repositoryContextFacts"; + +const { + compareRepositoryText, + repositoryContextCoverage, + repositoryContextEvidence, + repositoryContextFile, + repositoryContextId, + repositoryContextSource, + uniqueRepositorySources, +} = repositoryContextFacts; + +const PROVIDER = "cargo-metadata"; +const ECOSYSTEM = "cargo"; +const TARGET = "workspace"; + +interface ICargoMetadata { + packages: ICargoPackage[]; + workspace_members: string[]; + workspace_root: string; + resolve: { + nodes: Array<{ id: string; dependencies: string[]; features?: string[] }>; + } | null; +} + +interface ICargoPackage { + id: string; + name: string; + version: string; + manifest_path: string; + targets: ICargoTarget[]; +} + +interface ICargoTarget { + name: string; + kind: string[]; + crate_types: string[]; + src_path: string; +} + +export const cargoRepositoryContextProvider: IRepositoryContextProvider & { + collect: typeof collectCargoRepositoryContext; +} = { + name: PROVIDER, + ecosystem: ECOSYSTEM, + authority: "tool-resolved", + families: [ + "contains", + "depends-on", + "source-of", + "test-of", + "entrypoint-of", + "joins-file", + ], + buildInputs: [ + "Cargo.toml", + "Cargo.lock", + "rust-toolchain", + "rust-toolchain.toml", + ], + detect: (root) => fs.existsSync(path.join(root, "Cargo.toml")), + open: (props) => + createRepositoryContextSession( + cargoRepositoryContextProvider, + props, + collectCargoRepositoryContext, + ), + collect: collectCargoRepositoryContext, +}; + +function collectCargoRepositoryContext( + props: IRepositoryContextProvider.IOpenProps & { signal?: AbortSignal }, + execute: typeof executeCargoMetadata = executeCargoMetadata, +): IRepositoryContextProvider.ICollection { + throwIfAborted(props.signal); + const metadata = execute(props.root, props.env); + throwIfAborted(props.signal); + const members = new Set(metadata.workspace_members); + const workspaceCoordinate = repositoryContextFile( + props.root, + metadata.workspace_root, + ); + const workspaceId = repositoryContextId( + ECOSYSTEM, + "workspace", + workspaceCoordinate, + ); + const workspaceManifest = path.join(metadata.workspace_root, "Cargo.toml"); + const nodes: ISamchonRepositoryContextDump.INode[] = [ + { + id: workspaceId, + authority: "tool-resolved", + kind: "workspace", + name: path.basename(metadata.workspace_root), + ecosystem: ECOSYSTEM, + coordinate: workspaceCoordinate, + configuration: "default", + external: false, + evidence: repositoryContextEvidence(props.root, workspaceManifest), + }, + ]; + const edges: ISamchonRepositoryContextDump.IEdge[] = []; + const sources: ISamchonRepositoryContextDump.ISource[] = [ + repositoryContextSource(props.root, workspaceManifest), + ]; + const files = new Set(); + const packageIds = new Map(); + const configurations = new Map( + (metadata.resolve?.nodes ?? []).map((node) => [ + node.id, + cargoConfiguration(node.features), + ]), + ); + + for (const pkg of [...metadata.packages].sort((left, right) => + compareRepositoryText(left.id, right.id), + )) { + const member = members.has(pkg.id); + const coordinate = `${pkg.name}@${pkg.version}:${repositoryContextFile( + props.root, + path.dirname(pkg.manifest_path), + )}`; + const packageId = repositoryContextId( + ECOSYSTEM, + "package", + coordinate, + configurations.get(pkg.id) ?? "default", + ); + packageIds.set(pkg.id, packageId); + sources.push(repositoryContextSource(props.root, pkg.manifest_path)); + nodes.push({ + id: packageId, + authority: "tool-resolved", + kind: "package", + name: pkg.name, + ecosystem: ECOSYSTEM, + coordinate, + configuration: configurations.get(pkg.id) ?? "default", + external: !member, + evidence: repositoryContextEvidence(props.root, pkg.manifest_path), + }); + if (member) { + edges.push({ + authority: "tool-resolved", + kind: "contains", + from: workspaceId, + to: packageId, + }); + } + appendCargoTargets( + props.root, + pkg, + packageId, + configurations.get(pkg.id) ?? "default", + nodes, + edges, + files, + ); + } + + for (const resolved of metadata.resolve?.nodes ?? []) { + const from = packageIds.get(resolved.id); + if (from === undefined) continue; + for (const dependency of [...resolved.dependencies].sort( + compareRepositoryText, + )) { + const to = packageIds.get(dependency); + if (to !== undefined) { + edges.push({ + authority: "tool-resolved", + kind: "depends-on", + from, + to, + }); + } + } + } + + const shard = { + key: `${PROVIDER}:workspace`, + target: TARGET, + nodes: nodes.sort((left, right) => + compareRepositoryText(left.id, right.id), + ), + edges: dedupeEdges(edges), + coverage: repositoryContextCoverage( + PROVIDER, + ECOSYSTEM, + TARGET, + [ + "contains", + "depends-on", + "source-of", + "test-of", + "entrypoint-of", + "joins-file", + ], + ), + files: [...files].sort(compareRepositoryText), + sources: uniqueRepositorySources([ + ...sources, + ...metadata.packages + .filter((pkg) => members.has(pkg.id)) + .map((pkg) => path.dirname(path.dirname(pkg.manifest_path))) + .map((directory) => repositoryContextSource(props.root, directory)), + ...["Cargo.lock", "rust-toolchain", "rust-toolchain.toml"] + .map((file) => path.join(props.root, file)) + .filter((file) => fs.existsSync(file)) + .map((file) => repositoryContextSource(props.root, file)), + ]), + }; + return { + producerSchemaVersion: 1, + tool: "cargo metadata", + toolVersion: cargoVersion(props.root, props.env), + capabilities: [ + "workspace-members", + "resolved-dependencies", + "targets", + "features", + "source-files", + ], + universe: `${ECOSYSTEM}:${shard.sources + .map((source) => `${source.file}:${source.digest}`) + .join("|")}`, + target: TARGET, + shards: [shard], + warnings: [], + }; +} + +function appendCargoTargets( + root: string, + pkg: ICargoPackage, + packageId: string, + configuration: string, + nodes: ISamchonRepositoryContextDump.INode[], + edges: ISamchonRepositoryContextDump.IEdge[], + files: Set, +): void { + for (const target of [...pkg.targets].sort((left, right) => + compareRepositoryText( + `${left.name}:${left.kind.join(",")}`, + `${right.name}:${right.kind.join(",")}`, + ), + )) { + const targetCoordinate = `${pkg.id}#${target.name}:${target.kind.join("+")}`; + const targetId = repositoryContextId( + ECOSYSTEM, + "build-target", + targetCoordinate, + configuration, + ); + const sourceSetId = repositoryContextId( + ECOSYSTEM, + "source-set", + targetCoordinate, + configuration, + ); + const evidence = repositoryContextEvidence(root, pkg.manifest_path); + const file = repositoryContextFile(root, target.src_path); + nodes.push( + { + id: targetId, + authority: "tool-resolved", + kind: "build-target", + name: target.name, + ecosystem: ECOSYSTEM, + coordinate: targetCoordinate, + configuration, + external: !isInside(root, target.src_path), + file, + evidence, + }, + { + id: sourceSetId, + authority: "tool-resolved", + kind: "source-set", + name: target.kind.join("+"), + ecosystem: ECOSYSTEM, + coordinate: targetCoordinate, + configuration, + external: !isInside(root, target.src_path), + file, + evidence, + }, + ); + edges.push( + { + authority: "tool-resolved", + kind: "contains", + from: packageId, + to: targetId, + }, + { + authority: "tool-resolved", + kind: "contains", + from: targetId, + to: sourceSetId, + }, + { + authority: "tool-resolved", + kind: "source-of", + from: sourceSetId, + to: packageId, + }, + { + authority: "tool-resolved", + kind: "joins-file", + from: sourceSetId, + to: file, + }, + ); + files.add(file); + if (target.kind.includes("test") || target.kind.includes("bench")) { + edges.push({ + authority: "tool-resolved", + kind: "test-of", + from: sourceSetId, + to: packageId, + }); + } + if ( + target.kind.some((kind) => + ["bin", "example", "test", "bench"].includes(kind), + ) + ) { + const entrypointId = repositoryContextId( + ECOSYSTEM, + "entrypoint", + targetCoordinate, + configuration, + ); + nodes.push({ + id: entrypointId, + authority: "tool-resolved", + kind: "entrypoint", + name: target.name, + ecosystem: ECOSYSTEM, + coordinate: targetCoordinate, + configuration, + external: !isInside(root, target.src_path), + file, + evidence, + }); + edges.push( + { + authority: "tool-resolved", + kind: "contains", + from: targetId, + to: entrypointId, + }, + { + authority: "tool-resolved", + kind: "entrypoint-of", + from: entrypointId, + to: targetId, + }, + { + authority: "tool-resolved", + kind: "joins-file", + from: entrypointId, + to: file, + }, + ); + } + } +} + +function cargoConfiguration(features: readonly string[] | undefined): string { + if (features === undefined || features.length === 0) return "default"; + return `features=${[...features].sort(compareRepositoryText).join(",")}`; +} + +function executeCargoMetadata( + root: string, + env: NodeJS.ProcessEnv, +): ICargoMetadata { + /* c8 ignore next -- each coverage host has exactly one native shim suffix. */ + const command = process.platform === "win32" ? "cargo.cmd" : "cargo"; + const invocation = spawnableCommand( + command, + ["metadata", "--format-version", "1", "--locked", "--offline"], + env, + ); + const result = spawnSync(invocation.command, invocation.args, { + cwd: root, + env, + encoding: "utf8", + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }); + if (result.status !== 0) { + /* c8 ignore start -- direct-spawn errors and silent nonzero exits are + * operating-system fallbacks; stderr failures are exercised here. */ + const failure = + result.stderr || result.error?.message || "unknown error"; + /* c8 ignore stop */ + throw new Error( + `cargo metadata failed without changing the project: ${failure.trim()}`, + ); + } + const parsed = JSON.parse(result.stdout) as ICargoMetadata; + if ( + !Array.isArray(parsed.packages) || + !Array.isArray(parsed.workspace_members) || + typeof parsed.workspace_root !== "string" + ) { + throw new Error("cargo metadata returned a malformed model"); + } + return parsed; +} + +function cargoVersion(root: string, env: NodeJS.ProcessEnv): string { + /* c8 ignore next -- each coverage host has exactly one native shim suffix. */ + const command = process.platform === "win32" ? "cargo.cmd" : "cargo"; + const invocation = spawnableCommand(command, ["--version"], env); + const result = spawnSync(invocation.command, invocation.args, { + cwd: root, + env, + encoding: "utf8", + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }); + return result.status === 0 ? result.stdout.trim() : ""; +} + +function dedupeEdges( + input: readonly ISamchonRepositoryContextDump.IEdge[], +): ISamchonRepositoryContextDump.IEdge[] { + const rows = new Map(); + for (const edge of input) { + rows.set(`${edge.kind}\0${edge.from}\0${edge.to}`, edge); + } + return [...rows.values()].sort( + (left, right) => + compareRepositoryText(left.kind, right.kind) || + compareRepositoryText(left.from, right.from) || + compareRepositoryText(left.to, right.to), + ); +} + +function isInside(root: string, file: string): boolean { + const relative = path.relative(root, file); + return relative !== ".." && !relative.startsWith(`..${path.sep}`); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new Error("cargo repository context cancelled"); + } +} diff --git a/packages/graph/src/repository/cmakeRepositoryContextProvider.ts b/packages/graph/src/repository/cmakeRepositoryContextProvider.ts new file mode 100644 index 00000000..378338a6 --- /dev/null +++ b/packages/graph/src/repository/cmakeRepositoryContextProvider.ts @@ -0,0 +1,637 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { ISamchonRepositoryContextDump } from "../structures"; +import { IRepositoryContextProvider } from "./IRepositoryContextProvider"; +import { createRepositoryContextSession } from "./createRepositoryContextSession"; +import { repositoryContextFacts } from "./repositoryContextFacts"; + +const { + compareRepositoryText, + repositoryContextCoverage, + repositoryContextEvidence, + repositoryContextFile, + repositoryContextId, + repositoryContextSource, + uniqueRepositorySources, +} = repositoryContextFacts; + +const PROVIDER = "cmake-file-api"; +const ECOSYSTEM = "cmake"; + +interface ICmakeIndex { + cmake?: { version?: { string?: string } }; + reply?: Record; + objects?: Array<{ + kind?: string; + version?: { major?: number; minor?: number }; + jsonFile?: string; + }>; +} + +interface ICmakeCodemodel { + configurations: ICmakeConfiguration[]; + paths: { source: string; build: string }; +} + +interface ICmakeFiles { + paths: { source: string; build: string }; + inputs: Array<{ path: string }>; +} + +interface ICmakeConfiguration { + name: string; + projects: Array<{ + name: string; + directoryIndexes: number[]; + targetIndexes: number[]; + }>; + directories: Array<{ + source: string; + build: string; + projectIndex?: number; + targetIndexes: number[]; + }>; + targets: Array<{ + name: string; + id: string; + directoryIndex: number; + projectIndex: number; + jsonFile: string; + }>; +} + +interface ICmakeTarget { + name: string; + id: string; + type: string; + paths: { source: string; build: string }; + sources?: Array<{ path: string; isGenerated?: boolean }>; + dependencies?: Array<{ id: string }>; + artifacts?: Array<{ path: string }>; +} + +export const cmakeRepositoryContextProvider: IRepositoryContextProvider & { + collect: typeof collectCmakeRepositoryContext; +} = { + name: PROVIDER, + ecosystem: ECOSYSTEM, + authority: "tool-resolved", + families: [ + "contains", + "depends-on", + "source-of", + "produces", + "entrypoint-of", + "joins-file", + ], + buildInputs: [ + "CMakeLists.txt", + "CMakePresets.json", + "CMakeUserPresets.json", + ], + detect: (root) => fs.existsSync(path.join(root, "CMakeLists.txt")), + open: (props) => + createRepositoryContextSession( + cmakeRepositoryContextProvider, + props, + collectCmakeRepositoryContext, + ), + collect: collectCmakeRepositoryContext, +}; + +function collectCmakeRepositoryContext( + props: IRepositoryContextProvider.IOpenProps & { signal?: AbortSignal }, +): IRepositoryContextProvider.ICollection { + throwIfAborted(props.signal); + const reply = locateReply(props.root, props.env); + if (reply === undefined) { + throw new Error( + "CMake File API reply is unavailable. Configure the project with codemodel-v2 and cmakeFiles-v1 queries first; repository-context indexing will not write a query or run configuration implicitly.", + ); + } + const indexFile = latestIndex(reply); + const index = readJson(indexFile); + const codemodelRef = objectReference( + index, + "codemodel", + 2, + "codemodel-v2", + ); + const cmakeFilesRef = objectReference( + index, + "cmakeFiles", + 1, + "cmakeFiles-v1", + ); + if (codemodelRef === undefined || cmakeFilesRef === undefined) { + throw new Error( + "CMake File API index must contain codemodel-v2 and cmakeFiles-v1 replies", + ); + } + const codemodelFile = path.join(reply, codemodelRef); + const codemodel = readJson(codemodelFile); + const cmakeFilesFile = path.join(reply, cmakeFilesRef); + const cmakeFiles = readJson(cmakeFilesFile); + const modelInputs = [ + repositoryContextSource(props.root, cmakeFilesFile), + ...cmakeFiles.inputs.map((input) => + repositoryContextSource( + props.root, + path.resolve(cmakeFiles.paths.source, input.path), + ), + ), + ]; + const configurations = selectConfigurations( + codemodel.configurations, + props.env.SAMCHON_GRAPH_CMAKE_CONFIGURATION, + ); + const shards = configurations.map((configuration) => + cmakeConfigurationShard( + props.root, + reply, + indexFile, + codemodelFile, + codemodel, + configuration, + modelInputs, + ), + ); + throwIfAborted(props.signal); + const sources = uniqueRepositorySources( + shards.flatMap((shard) => shard.sources), + ); + return { + producerSchemaVersion: 1, + tool: "CMake File API", + toolVersion: index.cmake?.version?.string ?? "", + capabilities: [ + "codemodel-v2", + "cmakeFiles-v1", + "projects", + "targets", + "target-dependencies", + "sources", + "artifacts", + ], + universe: `${ECOSYSTEM}:${sources + .map((source) => `${source.file}:${source.digest}`) + .join("|")}`, + /* c8 ignore next -- selectConfigurations rejects an empty shard set. */ + target: shards[0]?.target ?? "default", + shards, + warnings: [ + "CMake context uses an existing File API reply and does not configure or mutate the project.", + ], + }; +} + +function objectReference( + index: ICmakeIndex, + kind: string, + major: number, + replyKey: string, +): string | undefined { + return ( + index.objects?.find( + (entry) => + entry.kind === kind && entry.version?.major === major, + )?.jsonFile ?? + Object.entries(index.reply ?? {}).find(([key]) => key === replyKey)?.[1] + .jsonFile + ); +} + +function cmakeConfigurationShard( + root: string, + reply: string, + indexFile: string, + codemodelFile: string, + codemodel: ICmakeCodemodel, + configuration: ICmakeConfiguration, + modelInputs: readonly ISamchonRepositoryContextDump.ISource[], +) { + const target = configuration.name || "default"; + const workspaceId = repositoryContextId( + ECOSYSTEM, + "workspace", + repositoryContextFile(root, codemodel.paths.source), + target, + ); + const evidenceFile = path.join(codemodel.paths.source, "CMakeLists.txt"); + const evidence = repositoryContextEvidence(root, evidenceFile); + const nodes: ISamchonRepositoryContextDump.INode[] = [ + { + id: workspaceId, + authority: "tool-resolved", + kind: "workspace", + name: path.basename(codemodel.paths.source), + ecosystem: ECOSYSTEM, + coordinate: repositoryContextFile(root, codemodel.paths.source), + configuration: target, + external: false, + evidence, + }, + ]; + const edges: ISamchonRepositoryContextDump.IEdge[] = []; + const files = new Set(); + const sources = [ + repositoryContextSource(root, indexFile), + repositoryContextSource(root, codemodelFile), + repositoryContextSource(root, evidenceFile), + ...modelInputs, + ...configuration.directories.map((directory) => + repositoryContextSource( + root, + path.join(codemodel.paths.source, directory.source, "CMakeLists.txt"), + ), + ), + ]; + // cmakeFiles-v1 is the owning tool's complete configuration-input list. + // Check every one of those inputs, including included `.cmake` modules, + // instead of guessing freshness from CMakeLists.txt names. + assertCmakeReplyFresh(indexFile, modelInputs.slice(1), root); + const projectIds = new Map(); + const targetIds = new Map(); + + configuration.projects.forEach((project, index) => { + const projectId = repositoryContextId( + ECOSYSTEM, + "project", + project.name, + target, + ); + projectIds.set(index, projectId); + nodes.push({ + id: projectId, + authority: "tool-resolved", + kind: "project", + name: project.name, + ecosystem: ECOSYSTEM, + coordinate: project.name, + configuration: target, + external: false, + evidence, + }); + edges.push({ + authority: "tool-resolved", + kind: "contains", + from: workspaceId, + to: projectId, + }); + }); + + const details = new Map(); + for (const summary of configuration.targets) { + const detailFile = path.join(reply, summary.jsonFile); + const detail = readJson(detailFile); + details.set(summary.id, detail); + sources.push(repositoryContextSource(root, detailFile)); + const projectId = projectIds.get(summary.projectIndex)!; + const targetId = repositoryContextId( + ECOSYSTEM, + "build-target", + summary.id, + target, + ); + targetIds.set(summary.id, targetId); + nodes.push({ + id: targetId, + authority: "tool-resolved", + kind: "build-target", + name: summary.name, + ecosystem: ECOSYSTEM, + coordinate: summary.id, + configuration: target, + external: false, + evidence, + }); + edges.push({ + authority: "tool-resolved", + kind: "contains", + from: projectId, + to: targetId, + }); + appendCmakeSources( + root, + target, + projectId, + targetId, + detail, + nodes, + edges, + files, + ); + if (detail.type === "EXECUTABLE") { + const entrypointId = repositoryContextId( + ECOSYSTEM, + "entrypoint", + summary.id, + target, + ); + nodes.push({ + id: entrypointId, + authority: "tool-resolved", + kind: "entrypoint", + name: detail.name, + ecosystem: ECOSYSTEM, + coordinate: summary.id, + configuration: target, + external: false, + evidence, + }); + edges.push( + { + authority: "tool-resolved", + kind: "contains", + from: targetId, + to: entrypointId, + }, + { + authority: "tool-resolved", + kind: "entrypoint-of", + from: entrypointId, + to: targetId, + }, + ); + } + for (const artifact of detail.artifacts ?? []) { + const artifactPath = path.resolve(detail.paths.build, artifact.path); + const coordinate = repositoryContextFile( + root, + path.dirname(artifactPath), + ); + const generatedId = repositoryContextId( + ECOSYSTEM, + "generated-root", + `${summary.id}:${coordinate}`, + target, + ); + nodes.push({ + id: generatedId, + authority: "tool-resolved", + kind: "generated-root", + name: path.basename(path.dirname(artifactPath)), + ecosystem: ECOSYSTEM, + coordinate, + configuration: target, + external: !isInside(root, artifactPath), + evidence, + }); + edges.push( + { + authority: "tool-resolved", + kind: "contains", + from: targetId, + to: generatedId, + }, + { + authority: "tool-resolved", + kind: "produces", + from: targetId, + to: generatedId, + }, + ); + } + } + for (const [id, detail] of details) { + const from = targetIds.get(id)!; + for (const dependency of detail.dependencies ?? []) { + const to = targetIds.get(dependency.id); + if (to !== undefined) { + edges.push({ + authority: "tool-resolved", + kind: "depends-on", + from, + to, + }); + } + } + } + return { + key: `${PROVIDER}:${target}`, + target, + nodes: dedupeNodes(nodes), + edges: dedupeEdges(edges), + coverage: repositoryContextCoverage( + PROVIDER, + ECOSYSTEM, + target, + [ + "contains", + "depends-on", + "source-of", + "produces", + "entrypoint-of", + "joins-file", + ], + ), + files: [...files].sort(compareRepositoryText), + sources: uniqueRepositorySources(sources), + }; +} + +function appendCmakeSources( + root: string, + configuration: string, + projectId: string, + targetId: string, + detail: ICmakeTarget, + nodes: ISamchonRepositoryContextDump.INode[], + edges: ISamchonRepositoryContextDump.IEdge[], + files: Set, +): void { + const roots = new Map< + string, + { generated: boolean; files: string[] } + >(); + for (const source of detail.sources ?? []) { + // Codemodel-v2 makes a source path relative only when it lies inside the + // top-level source tree; generated files outside that tree are absolute. + const absolute = path.resolve(detail.paths.source, source.path); + const directory = path.dirname(absolute); + const row = roots.get(directory) ?? { + generated: source.isGenerated === true, + files: [], + }; + row.generated ||= source.isGenerated === true; + row.files.push(absolute); + roots.set(directory, row); + } + for (const [directory, row] of [...roots].sort(([left], [right]) => + compareRepositoryText(left, right), + )) { + const coordinate = `${detail.id}:${repositoryContextFile(root, directory)}`; + const sourceId = repositoryContextId( + ECOSYSTEM, + row.generated ? "generated-root" : "source-root", + coordinate, + configuration, + ); + nodes.push({ + id: sourceId, + authority: "tool-resolved", + kind: row.generated ? "generated-root" : "source-root", + name: path.basename(directory), + ecosystem: ECOSYSTEM, + coordinate, + configuration, + external: !isInside(root, directory), + evidence: repositoryContextEvidence( + root, + path.join(detail.paths.source, "CMakeLists.txt"), + ), + }); + edges.push( + { + authority: "tool-resolved", + kind: "contains", + from: targetId, + to: sourceId, + }, + { + authority: "tool-resolved", + kind: "source-of", + from: sourceId, + to: projectId, + }, + ); + for (const file of row.files) { + const joined = repositoryContextFile(root, file); + files.add(joined); + edges.push({ + authority: "tool-resolved", + kind: "joins-file", + from: sourceId, + to: joined, + }); + } + } +} + +function assertCmakeReplyFresh( + indexFile: string, + sources: readonly ISamchonRepositoryContextDump.ISource[], + root: string, +): void { + const replyTime = fs.statSync(indexFile).mtimeMs; + for (const source of sources) { + const file = path.resolve(root, source.file); + if (!fs.existsSync(file)) { + throw new Error( + `CMake File API input ${source.file} is missing; reconfigure the project before repository-context indexing.`, + ); + } + if (fs.statSync(file).mtimeMs > replyTime) { + throw new Error( + `CMake File API reply predates ${source.file}; reconfigure the project before repository-context indexing.`, + ); + } + } +} + +function selectConfigurations( + configurations: readonly ICmakeConfiguration[], + requested: string | undefined, +): readonly ICmakeConfiguration[] { + if (configurations.length === 0) { + throw new Error("CMake File API codemodel has no configuration"); + } + if (configurations.length <= 1) return configurations; + if (requested !== undefined) { + const selected = configurations.find( + (configuration) => configuration.name === requested, + ); + if (selected !== undefined) return [selected]; + } + throw new Error( + "CMake File API returned multiple configurations; select one with SAMCHON_GRAPH_CMAKE_CONFIGURATION before joining it to one repository-context generation.", + ); +} + +function locateReply( + root: string, + env: NodeJS.ProcessEnv, +): string | undefined { + const candidates = [ + env.SAMCHON_GRAPH_CMAKE_REPLY, + path.join(root, ".cmake", "api", "v1", "reply"), + path.join(root, "build", ".cmake", "api", "v1", "reply"), + path.join(root, "cmake-build-debug", ".cmake", "api", "v1", "reply"), + path.join(root, "cmake-build-release", ".cmake", "api", "v1", "reply"), + ].filter((value): value is string => value !== undefined); + return candidates.map((value) => path.resolve(value)).find((value) => + fs.existsSync(value), + ); +} + +function latestIndex(reply: string): string { + const files = fs + .readdirSync(reply) + .filter((file) => /^(?:index|error)-.*\.json$/.test(file)) + .map((file) => ({ + file, + generation: file.slice(file.indexOf("-") + 1), + })) + .sort((left, right) => { + const generation = compareRepositoryText( + left.generation, + right.generation, + ); + return generation !== 0 + ? generation + : Number(left.file.startsWith("error-")) - + Number(right.file.startsWith("error-")); + }) + .map((entry) => entry.file); + const latest = files.at(-1); + if (latest === undefined) { + throw new Error("CMake File API reply directory has no index"); + } + if (latest.startsWith("error-")) { + throw new Error( + `CMake File API latest reply reports a configuration error: ${latest}`, + ); + } + return path.join(reply, latest); +} + +function readJson(file: string): T { + return JSON.parse(fs.readFileSync(file, "utf8")) as T; +} + +function dedupeNodes( + input: readonly ISamchonRepositoryContextDump.INode[], +): ISamchonRepositoryContextDump.INode[] { + const rows = new Map(); + for (const node of input) rows.set(node.id, node); + return [...rows.values()].sort((left, right) => + compareRepositoryText(left.id, right.id), + ); +} + +function dedupeEdges( + input: readonly ISamchonRepositoryContextDump.IEdge[], +): ISamchonRepositoryContextDump.IEdge[] { + const rows = new Map(); + for (const edge of input) { + rows.set(`${edge.kind}\0${edge.from}\0${edge.to}`, edge); + } + return [...rows.values()].sort( + (left, right) => + compareRepositoryText(left.kind, right.kind) || + compareRepositoryText(left.from, right.from) || + compareRepositoryText(left.to, right.to), + ); +} + +function isInside(root: string, file: string): boolean { + const relative = path.relative(root, file); + return relative !== ".." && !relative.startsWith(`..${path.sep}`); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new Error("CMake repository context cancelled"); + } +} diff --git a/packages/graph/src/repository/createRepositoryContextSession.ts b/packages/graph/src/repository/createRepositoryContextSession.ts new file mode 100644 index 00000000..31641a9b --- /dev/null +++ b/packages/graph/src/repository/createRepositoryContextSession.ts @@ -0,0 +1,275 @@ +import path from "node:path"; + +import { IRepositoryContextProvider } from "./IRepositoryContextProvider"; +import { IRepositoryContextSession } from "./IRepositoryContextSession"; +import { RepositoryContextProtocol } from "./RepositoryContextProtocol"; +import { repositoryContextFacts } from "./repositoryContextFacts"; + +const { repositoryContextPathDigest } = repositoryContextFacts; + +/** Build the common atomic resident shell around an owning-tool collector. */ +export function createRepositoryContextSession( + provider: Pick< + IRepositoryContextProvider, + "name" | "ecosystem" | "authority" | "families" | "buildInputs" + >, + props: IRepositoryContextProvider.IOpenProps, + collect: IRepositoryContextProvider.Collector, +): IRepositoryContextSession { + const store = new RepositoryContextProtocol.Store(); + let generation = 0; + let closed = false; + let inputState: string | undefined; + let queue = Promise.resolve(); + let currentWarnings: string[] = []; + + return { + kind: "repository-context", + provider: provider.name, + ecosystem: provider.ecosystem, + root: props.root, + get generation() { + return generation; + }, + get current() { + return store.current; + }, + refresh(options = {}) { + return enqueue(async () => { + assertOpen(); + throwIfAborted(options.signal); + const observed = createRepositoryContextSession.observeInputGeneration( + props.root, + provider.buildInputs, + store.current?.sources.map((source) => source.file), + props.env, + ); + if (store.current !== undefined && observed === inputState) { + return { + changed: false, + generation, + mode: "unchanged" as const, + snapshot: store.current, + warnings: [...currentWarnings], + }; + } + + const collected = await collect({ ...props, signal: options.signal }); + assertOpen(); + throwIfAborted(options.signal); + const sources = collected.shards.flatMap((shard) => shard.sources); + const afterCollection = + createRepositoryContextSession.observeInputGeneration( + props.root, + provider.buildInputs, + sources.map((source) => source.file), + props.env, + ); + const consumed = consumedInputState( + props.root, + provider.buildInputs, + sources, + props.env, + ); + if (afterCollection !== consumed) { + throw new Error( + `repository context provider ${provider.name} inputs changed while its model was being collected`, + ); + } + const manifest = RepositoryContextProtocol.manifestDigest(sources); + const sequence = generation + 1; + const token = RepositoryContextProtocol.digest({ + provider: provider.name, + sequence, + universe: collected.universe, + manifest, + }); + const previous = store.current; + const priorShards = new Map( + previous?.generation.shards.map((entry) => [entry.key, entry.digest]) ?? + [], + ); + const nextShards = new Map( + collected.shards.map((shard) => [ + shard.key, + RepositoryContextProtocol.shardDigest(shard), + ]), + ); + const frames: RepositoryContextProtocol.Frame[] = [ + { + type: "hello", + protocolVersion: 1, + schemaVersion: 1, + producerSchemaVersion: collected.producerSchemaVersion, + provider: provider.name, + ecosystem: provider.ecosystem, + authority: provider.authority, + tool: collected.tool, + toolVersion: collected.toolVersion, + supportedFamilies: [...provider.families], + capabilities: [...collected.capabilities], + }, + { + type: "begin", + sequence, + generation: token, + ...(previous !== undefined + ? { + baseSequence: previous.generation.sequence, + baseGeneration: previous.generation.token, + } + : {}), + inputGeneration: RepositoryContextProtocol.digest({ + universe: collected.universe, + manifest, + }), + universe: collected.universe, + target: collected.target, + manifest, + }, + ]; + for (const key of [...priorShards.keys()].sort(compare)) { + if (!nextShards.has(key)) frames.push({ type: "deleteShard", key }); + } + for (const shard of [...collected.shards].sort((left, right) => + compare(left.key, right.key), + )) { + const digest = nextShards.get(shard.key)!; + if (priorShards.get(shard.key) !== digest) { + frames.push({ + type: "upsertShard", + digest, + shard, + }); + } + } + const facts = { + nodes: collected.shards.flatMap((shard) => shard.nodes), + edges: collected.shards.flatMap((shard) => shard.edges), + coverage: collected.shards.flatMap((shard) => shard.coverage), + }; + frames.push({ + type: "commit", + sequence, + generation: token, + shards: [...nextShards] + .sort(([left], [right]) => compare(left, right)) + .map(([key, digest]) => ({ key, digest })), + contentDigest: RepositoryContextProtocol.contentDigest(facts), + }); + const snapshot = store.apply(frames, options); + generation = sequence; + currentWarnings = [...collected.warnings]; + inputState = createRepositoryContextSession.observeInputGeneration( + props.root, + provider.buildInputs, + snapshot.sources.map((source) => source.file), + props.env, + ); + return { + changed: true, + generation, + mode: + previous === undefined + ? ("initial" as const) + : previous.begin.universe === snapshot.begin.universe + ? ("incremental" as const) + : ("reload" as const), + snapshot, + warnings: [...currentWarnings], + }; + }); + }, + close() { + closed = true; + return queue; + }, + }; + + function enqueue(task: () => Promise): Promise { + const result = queue.catch(() => undefined).then(task); + queue = result.then(() => undefined).catch(() => undefined); + return result; + } + + function assertOpen(): void { + if (closed) { + throw new Error( + `repository context provider ${provider.name} is closed`, + ); + } + } +} + +export namespace createRepositoryContextSession { +/** Fingerprint the current declared and previously published provider inputs. */ + export function observeInputGeneration( + root: string, + declared: readonly string[], + published: readonly string[] | undefined, + env: NodeJS.ProcessEnv, +): string { + const files = new Set([ + ...declared.map((file) => normalize(root, file)), + ...(published ?? []).map((file) => normalize(root, file)), + ]); + const rows = [...files].sort(compare).map((file) => ({ + file: relative(root, file), + digest: repositoryContextPathDigest(file), + })); + return RepositoryContextProtocol.digest({ + rows, + path: env.PATH ?? "", + }); +} +/* c8 ignore start -- declaration merging emits a namespace creation arm after + * the function object already exists, so that arm is unreachable. */ +} +/* c8 ignore stop */ + +function consumedInputState( + root: string, + declared: readonly string[], + published: readonly { file: string; digest: string }[], + env: NodeJS.ProcessEnv, +): string { + const consumed = new Map( + published.map((source) => [ + normalize(root, source.file), + source.digest, + ]), + ); + for (const file of declared.map((entry) => normalize(root, entry))) { + if (!consumed.has(file)) { + consumed.set(file, repositoryContextPathDigest(file)); + } + } + return RepositoryContextProtocol.digest({ + rows: [...consumed] + .sort(([left], [right]) => compare(left, right)) + .map(([file, digest]) => ({ + file: relative(root, file), + digest, + })), + path: env.PATH ?? "", + }); +} + +function normalize(root: string, file: string): string { + return path.resolve(root, file); +} + +function relative(root: string, file: string): string { + return path.relative(root, file).replaceAll("\\", "/") || "."; +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new Error("repository context provider refresh cancelled"); + } +} + +function compare(left: string, right: string): number { + /* c8 ignore next -- canonical input and shard sets contain distinct keys. */ + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/graph/src/repository/createResidentRepositoryContextMemorySource.ts b/packages/graph/src/repository/createResidentRepositoryContextMemorySource.ts new file mode 100644 index 00000000..3d8787e3 --- /dev/null +++ b/packages/graph/src/repository/createResidentRepositoryContextMemorySource.ts @@ -0,0 +1,20 @@ +import { SamchonRepositoryContextMemory } from "./SamchonRepositoryContextMemory"; +import { IResidentRepositoryContextSource } from "./IResidentRepositoryContextSource"; + +/** Reuse the exact topology memory while its resident dump identity is stable. */ +export function createResidentRepositoryContextMemorySource( + resident: IResidentRepositoryContextSource, +): () => Promise { + let currentDump: + | Awaited> + | undefined; + let currentMemory: SamchonRepositoryContextMemory | undefined; + return async () => { + const dump = await resident.load(); + if (currentMemory === undefined || dump !== currentDump) { + currentDump = dump; + currentMemory = new SamchonRepositoryContextMemory(dump); + } + return currentMemory; + }; +} diff --git a/packages/graph/src/repository/createResidentRepositoryContextSource.ts b/packages/graph/src/repository/createResidentRepositoryContextSource.ts new file mode 100644 index 00000000..ef0b5344 --- /dev/null +++ b/packages/graph/src/repository/createResidentRepositoryContextSource.ts @@ -0,0 +1,340 @@ +import path from "node:path"; + +import { ISamchonRepositoryContextDump } from "../structures"; +import { IRepositoryContextProvider } from "./IRepositoryContextProvider"; +import { IRepositoryContextSession } from "./IRepositoryContextSession"; +import { IResidentRepositoryContextSource } from "./IResidentRepositoryContextSource"; +import { REPOSITORY_CONTEXT_PROVIDERS } from "./REPOSITORY_CONTEXT_PROVIDERS"; +import { RepositoryContextProtocol } from "./RepositoryContextProtocol"; +import { createRepositoryContextSession } from "./createRepositoryContextSession"; +import { repositoryContextFacts } from "./repositoryContextFacts"; + +const { compareRepositoryText } = repositoryContextFacts; + +/** Open and atomically merge every detected repository-context provider. */ +export function createResidentRepositoryContextSource( + root: string, + env: NodeJS.ProcessEnv = process.env, + providers: readonly IRepositoryContextProvider[] = REPOSITORY_CONTEXT_PROVIDERS, +): IResidentRepositoryContextSource { + const project = path.resolve(root); + const sessions = providers + .filter((provider) => provider.detect(project)) + .map((provider) => ({ + provider, + session: provider.open({ root: project, env }), + })); + let current: ISamchonRepositoryContextDump | undefined; + let sequence = 0; + let queue = Promise.resolve(); + let closed = false; + let priorStates: string[] = []; + + return { + load(options = {}) { + return enqueue(async () => { + assertOpen(); + const snapshots: Array<{ + provider: IRepositoryContextProvider; + snapshot: NonNullable; + warnings: string[]; + }> = []; + const failures: IProviderFailure[] = []; + for (const row of sessions) { + try { + const refresh = await row.session.refresh(options); + snapshots.push({ + provider: row.provider, + snapshot: refresh.snapshot, + warnings: refresh.warnings, + }); + } catch (error) { + if (options.signal?.aborted) throw error; + failures.push({ + provider: row.provider, + inputGeneration: + createRepositoryContextSession.observeInputGeneration( + project, + row.provider.buildInputs, + row.session.current?.sources.map((source) => source.file), + env, + ), + message: error instanceof Error ? error.message : String(error), + }); + } + } + const states = [ + ...snapshots.map(snapshotIdentity), + ...failures.map(failureIdentity), + ].sort(compareRepositoryText); + if ( + sameStrings(states, priorStates) && + current !== undefined + ) { + return current; + } + const next = assemble(project, sequence + 1, snapshots, failures); + sequence = next.generation.sequence; + priorStates = states; + current = next; + return next; + }); + }, + close() { + closed = true; + return enqueue(async () => { + let failure: Error | undefined; + for (const row of sessions) { + try { + await row.session.close(); + } catch (error) { + failure ??= + error instanceof Error ? error : new Error(String(error)); + } + } + if (failure !== undefined) throw failure; + }, true); + }, + }; + + function enqueue( + task: () => Promise, + allowClosed = false, + ): Promise { + const result = queue + .catch(() => undefined) + .then(() => { + if (!allowClosed) assertOpen(); + return task(); + }); + queue = result.then(() => undefined).catch(() => undefined); + return result; + } + + function assertOpen(): void { + if (closed) { + throw new Error("repository context source is closed"); + } + } +} + +function sameStrings( + left: readonly string[], + right: readonly string[], +): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +interface IProviderFailure { + provider: IRepositoryContextProvider; + inputGeneration: string; + message: string; +} + +function failureIdentity(failure: IProviderFailure): string { + return [ + failure.provider.name, + failure.inputGeneration, + failure.message, + ].join("\0"); +} + +function snapshotIdentity(snapshot: { + provider: IRepositoryContextProvider; + snapshot: RepositoryContextProtocol.ISnapshot; + warnings: readonly string[]; +}): string { + return RepositoryContextProtocol.digest({ + provider: snapshot.provider.name, + inputGeneration: snapshot.snapshot.begin.inputGeneration, + generation: snapshot.snapshot.generation.token, + content: snapshot.snapshot.generation.contentDigest, + warnings: [...snapshot.warnings].sort(compareRepositoryText), + }); +} + +function assemble( + project: string, + sequence: number, + rows: readonly { + provider: IRepositoryContextProvider; + snapshot: RepositoryContextProtocol.ISnapshot; + warnings: readonly string[]; + }[], + failures: readonly IProviderFailure[], +): ISamchonRepositoryContextDump { + const nodes = rows.flatMap((row) => row.snapshot.nodes); + const edges = rows.flatMap((row) => row.snapshot.edges); + const coverage = rows.flatMap((row) => row.snapshot.coverage); + for (const failure of failures) { + coverage.push( + ...RepositoryContextProtocol.RELATION_KINDS.map((family) => ({ + provider: failure.provider.name, + ecosystem: failure.provider.ecosystem, + target: "unavailable", + family, + state: "unsupported" as const, + })), + ); + } + const sources = mergeSources(rows.flatMap((row) => row.snapshot.sources)); + const shards = rows + .flatMap((row) => + row.snapshot.generation.shards.map((shard) => ({ + key: `${row.provider.name}/${shard.key}`, + digest: shard.digest, + })), + ) + .sort((left, right) => compareRepositoryText(left.key, right.key)); + const inputGeneration = RepositoryContextProtocol.digest( + [ + ...rows.map((row) => ({ + provider: row.provider.name, + generation: row.snapshot.begin.inputGeneration, + })), + ...failures.map((failure) => ({ + provider: failure.provider.name, + generation: failure.inputGeneration, + })), + ] + .sort((left, right) => + compareRepositoryText(left.provider, right.provider), + ), + ); + const contentDigest = RepositoryContextProtocol.digest({ + nodes: [...nodes].sort((left, right) => + compareRepositoryText(left.id, right.id), + ), + edges: [...edges].sort( + (left, right) => + compareRepositoryText(left.kind, right.kind) || + compareRepositoryText(left.from, right.from) || + compareRepositoryText(left.to, right.to), + ), + coverage, + }); + const dump: ISamchonRepositoryContextDump = { + project, + schemaVersion: 1, + inputGeneration, + generation: { + sequence, + token: RepositoryContextProtocol.digest({ + sequence, + inputGeneration, + contentDigest, + }), + shards, + contentDigest, + }, + provenance: rows + .map(({ provider, snapshot }) => ({ + provider: provider.name, + ecosystem: provider.ecosystem, + authority: provider.authority, + tool: snapshot.hello.tool, + toolVersion: snapshot.hello.toolVersion, + schemaVersion: snapshot.hello.producerSchemaVersion, + protocolVersion: snapshot.hello.protocolVersion, + universe: snapshot.begin.universe, + manifest: snapshot.begin.manifest, + content: snapshot.generation.contentDigest, + capabilities: [...snapshot.hello.capabilities], + })) + .sort((left, right) => + compareRepositoryText(left.provider, right.provider), + ), + coverage: coverage.sort( + (left, right) => + compareRepositoryText(left.provider, right.provider) || + compareRepositoryText(left.family, right.family), + ), + nodes: dedupeNodes(nodes), + edges: dedupeEdges(edges), + files: [ + ...new Set(rows.flatMap((row) => row.snapshot.files)), + ].sort(compareRepositoryText), + sources, + warnings: [ + ...rows.flatMap((row) => row.warnings), + ...failures.map( + (failure) => + `repository context unavailable: ${failure.provider.name}: ${failure.message}`, + ), + ].sort(compareRepositoryText), + }; + freeze(dump); + return dump; +} + +function mergeSources( + input: readonly ISamchonRepositoryContextDump.ISource[], +): ISamchonRepositoryContextDump.ISource[] { + const rows = new Map(); + for (const source of input) { + const prior = rows.get(source.file); + if (prior !== undefined && prior !== source.digest) { + throw new Error( + `repository context providers disagree about input ${source.file}`, + ); + } + rows.set(source.file, source.digest); + } + return [...rows] + .sort(([left], [right]) => compareRepositoryText(left, right)) + .map(([file, digest]) => ({ file, digest })); +} + +function dedupeNodes( + input: readonly ISamchonRepositoryContextDump.INode[], +): ISamchonRepositoryContextDump.INode[] { + const rows = new Map(); + for (const node of input) { + if (rows.has(node.id)) { + throw new Error( + `repository context providers published duplicate node ${node.id}`, + ); + } + rows.set(node.id, node); + } + return [...rows.values()].sort((left, right) => + compareRepositoryText(left.id, right.id), + ); +} + +function dedupeEdges( + input: readonly ISamchonRepositoryContextDump.IEdge[], +): ISamchonRepositoryContextDump.IEdge[] { + const rows = new Map(); + for (const edge of input) { + const key = `${edge.kind}\0${edge.from}\0${edge.to}`; + /* c8 ignore start -- an equal edge requires equal endpoint identities, + * which dedupeNodes rejects before edge deduplication is reached. */ + if (rows.has(key)) { + throw new Error( + `repository context providers published duplicate edge ${edge.kind}`, + ); + } + /* c8 ignore stop */ + rows.set(key, edge); + } + return [...rows.values()].sort( + /* c8 ignore start -- edge tuple keys are distinct after the guard above. */ + (left, right) => + compareRepositoryText(left.kind, right.kind) || + compareRepositoryText(left.from, right.from) || + compareRepositoryText(left.to, right.to), + /* c8 ignore stop */ + ); +} + +function freeze(value: unknown): void { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) { + return; + } + Object.freeze(value); + for (const child of Object.values(value)) freeze(child); +} diff --git a/packages/graph/src/repository/gradleRepositoryContextProvider.ts b/packages/graph/src/repository/gradleRepositoryContextProvider.ts new file mode 100644 index 00000000..514144a7 --- /dev/null +++ b/packages/graph/src/repository/gradleRepositoryContextProvider.ts @@ -0,0 +1,406 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import { ISamchonRepositoryContextDump } from "../structures"; +import { IRepositoryContextProvider } from "./IRepositoryContextProvider"; +import { createRepositoryContextSession } from "./createRepositoryContextSession"; +import { parseGradleRepositoryContextModel } from "./parseGradleRepositoryContextModel"; +import { repositoryContextFacts } from "./repositoryContextFacts"; + +const { + compareRepositoryText, + repositoryContextCoverage, + repositoryContextEvidence, + repositoryContextFile, + repositoryContextId, + repositoryContextSource, + uniqueRepositorySources, +} = repositoryContextFacts; + +const PROVIDER = "gradle-tooling-api"; +const ECOSYSTEM = "gradle"; +const TARGET = "workspace"; + +export const gradleRepositoryContextProvider: IRepositoryContextProvider & { + collect: typeof collectGradleRepositoryContext; +} = { + name: PROVIDER, + ecosystem: ECOSYSTEM, + authority: "tool-resolved", + families: [ + "contains", + "depends-on", + "source-of", + "test-of", + "joins-file", + ], + buildInputs: [ + "settings.gradle", + "settings.gradle.kts", + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "gradle/wrapper/gradle-wrapper.properties", + ], + detect: (root) => + ["settings.gradle", "settings.gradle.kts"].some((file) => + fs.existsSync(path.join(root, file)), + ), + open: (props) => + createRepositoryContextSession( + gradleRepositoryContextProvider, + props, + collectGradleRepositoryContext, + ), + collect: collectGradleRepositoryContext, +}; + +function collectGradleRepositoryContext( + props: IRepositoryContextProvider.IOpenProps & { signal?: AbortSignal }, + execute: typeof executeGradleModel = executeGradleModel, +): IRepositoryContextProvider.ICollection { + throwIfAborted(props.signal); + if (props.env.SAMCHON_GRAPH_ALLOW_GRADLE_MODEL !== "1") { + throw new Error( + "Gradle repository context is disabled until SAMCHON_GRAPH_ALLOW_GRADLE_MODEL=1 acknowledges that the Tooling API evaluates project build configuration; no task is run.", + ); + } + const model = execute(props.root, props.env); + throwIfAborted(props.signal); + const workspaceId = repositoryContextId(ECOSYSTEM, "workspace", "."); + const settings = firstExisting(props.root, [ + "settings.gradle", + "settings.gradle.kts", + ]); + const nodes: ISamchonRepositoryContextDump.INode[] = [ + { + id: workspaceId, + authority: "tool-resolved", + kind: "workspace", + name: path.basename(props.root), + ecosystem: ECOSYSTEM, + coordinate: ".", + configuration: "default", + external: false, + evidence: repositoryContextEvidence(props.root, settings), + }, + ]; + const edges: ISamchonRepositoryContextDump.IEdge[] = []; + const files = new Set(); + const sources = gradleInputs(props.root).map((file) => + repositoryContextSource(props.root, file), + ); + const projectIds = new Map(); + const names = new Map(); + + for (const module of [...model.modules].sort((left, right) => + compareRepositoryText(left.path, right.path), + )) { + const projectId = repositoryContextId( + ECOSYSTEM, + "project", + module.path, + ); + const buildTargetId = repositoryContextId( + ECOSYSTEM, + "build-target", + module.path, + ); + projectIds.set(module.path, projectId); + names.set(module.name, [...(names.get(module.name) ?? []), projectId]); + const buildFile = firstExisting(module.directory, [ + "build.gradle", + "build.gradle.kts", + ]); + sources.push(repositoryContextSource(props.root, buildFile)); + const evidence = repositoryContextEvidence(props.root, buildFile); + nodes.push( + { + id: projectId, + authority: "tool-resolved", + kind: "project", + name: module.name, + ecosystem: ECOSYSTEM, + coordinate: module.path, + configuration: "default", + external: false, + evidence, + }, + { + id: buildTargetId, + authority: "tool-resolved", + kind: "build-target", + name: module.path, + ecosystem: ECOSYSTEM, + coordinate: module.path, + configuration: "default", + external: false, + evidence, + }, + ); + edges.push( + { + authority: "tool-resolved", + kind: "contains", + from: workspaceId, + to: projectId, + }, + { + authority: "tool-resolved", + kind: "contains", + from: projectId, + to: buildTargetId, + }, + ); + for (const source of module.sources) { + const coordinate = `${module.path}:${repositoryContextFile( + props.root, + source.directory, + )}`; + const sourceId = repositoryContextId( + ECOSYSTEM, + source.generated ? "generated-root" : "source-root", + coordinate, + ); + nodes.push({ + id: sourceId, + authority: "tool-resolved", + kind: source.generated ? "generated-root" : "source-root", + name: path.basename(source.directory), + ecosystem: ECOSYSTEM, + coordinate, + configuration: source.kind, + external: !isInside(props.root, source.directory), + root: repositoryContextFile(props.root, source.directory), + evidence, + }); + edges.push( + { + authority: "tool-resolved", + kind: "contains", + from: buildTargetId, + to: sourceId, + }, + { + authority: "tool-resolved", + kind: "source-of", + from: sourceId, + to: projectId, + }, + ); + if (source.kind.startsWith("test")) { + edges.push({ + authority: "tool-resolved", + kind: "test-of", + from: sourceId, + to: projectId, + }); + } + } + for (const task of module.tasks) { + const taskId = repositoryContextId( + ECOSYSTEM, + "task", + task.path, + ); + nodes.push({ + id: taskId, + authority: "tool-resolved", + kind: "task", + name: task.name, + ecosystem: ECOSYSTEM, + coordinate: task.path, + configuration: "default", + external: false, + evidence, + }); + edges.push({ + authority: "tool-resolved", + kind: "contains", + from: projectId, + to: taskId, + }); + } + } + + let unresolvedDependencies = 0; + for (const module of model.modules) { + const from = projectIds.get(module.path)!; + for (const dependency of module.dependencies) { + const candidates = names.get(dependency) ?? []; + const to = + projectIds.get(dependency) ?? + (candidates.length === 1 ? candidates[0] : undefined); + if (to !== undefined) { + edges.push({ + authority: "tool-resolved", + kind: "depends-on", + from, + to, + }); + } + else unresolvedDependencies += 1; + } + } + const shard = { + key: `${PROVIDER}:workspace`, + target: TARGET, + nodes: nodes.sort((left, right) => + compareRepositoryText(left.id, right.id), + ), + edges: dedupeEdges(edges), + coverage: repositoryContextCoverage( + PROVIDER, + ECOSYSTEM, + TARGET, + [ + "contains", + ...(unresolvedDependencies === 0 ? ["depends-on" as const] : []), + "source-of", + "test-of", + "joins-file", + ], + unresolvedDependencies === 0 ? [] : ["depends-on"], + ), + files: [...files].sort(compareRepositoryText), + sources: uniqueRepositorySources(sources), + }; + return { + producerSchemaVersion: 1, + tool: "Gradle Tooling API", + toolVersion: model.version, + capabilities: [ + "projects", + "project-dependencies", + "source-directories", + "tasks", + "daemon-reuse", + ], + universe: `${ECOSYSTEM}:${shard.sources + .map((source) => `${source.file}:${source.digest}`) + .join("|")}`, + target: TARGET, + shards: [shard], + warnings: [ + "Gradle Tooling API model evaluation was explicitly enabled; no build task was invoked.", + ...(unresolvedDependencies === 0 + ? [] + : [ + `${unresolvedDependencies} Gradle project dependencies had ambiguous or absent Tooling API module identities.`, + ]), + ], + }; +} + +function executeGradleModel( + root: string, + env: NodeJS.ProcessEnv, +): parseGradleRepositoryContextModel.IModel { + const classpath = gradleToolingClasspath(env); + if (classpath === undefined) { + throw new Error( + "Gradle Tooling API classpath is unavailable; set SAMCHON_GRAPH_GRADLE_TOOLING_CLASSPATH or GRADLE_HOME without downloading or mutating the project.", + ); + } + /* c8 ignore next 4 -- a coverage host exercises exactly one native Java + * executable suffix; JAVA_HOME and PATH selection are both tested. */ + const java = + env.JAVA_HOME !== undefined + ? path.join(env.JAVA_HOME, "bin", process.platform === "win32" ? "java.exe" : "java") + : "java"; + const source = path.resolve( + __dirname, + "..", + "..", + "sidecars", + "gradle", + "RepositoryContext.java", + ); + const result = spawnSync( + java, + ["--class-path", classpath, source, path.resolve(root)], + { + cwd: root, + env, + encoding: "utf8", + windowsHide: true, + }, + ); + if (result.status !== 0) { + /* c8 ignore start -- direct-spawn error details differ by operating + * system; explicit classpath, GRADLE_HOME and failure paths are tested. */ + const failure = + result.stderr || result.error?.message || "unknown error"; + /* c8 ignore stop */ + throw new Error( + `Gradle Tooling API model failed: ${failure.trim()}`, + ); + } + /* c8 ignore start -- a successful external JVM boundary needs an installed + * Tooling API; its complete output parser is tested independently. */ + return parseGradleRepositoryContextModel(result.stdout); +} +/* c8 ignore stop */ + +function gradleToolingClasspath( + env: NodeJS.ProcessEnv, +): string | undefined { + if (env.SAMCHON_GRAPH_GRADLE_TOOLING_CLASSPATH?.trim()) { + return env.SAMCHON_GRAPH_GRADLE_TOOLING_CLASSPATH; + } + if (!env.GRADLE_HOME?.trim()) return undefined; + return [ + path.join(env.GRADLE_HOME, "lib", "*"), + path.join(env.GRADLE_HOME, "lib", "plugins", "*"), + ].join(path.delimiter); +} + +function gradleInputs(root: string): string[] { + return [ + "settings.gradle", + "settings.gradle.kts", + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "gradle/wrapper/gradle-wrapper.properties", + ] + .map((file) => path.join(root, file)) + .filter((file) => fs.existsSync(file)); +} + +function firstExisting(root: string, candidates: readonly string[]): string { + return ( + candidates + .map((file) => path.join(root, file)) + .find((file) => fs.existsSync(file)) ?? path.join(root, candidates[0]!) + ); +} + +function dedupeEdges( + input: readonly ISamchonRepositoryContextDump.IEdge[], +): ISamchonRepositoryContextDump.IEdge[] { + const rows = new Map(); + for (const edge of input) { + rows.set(`${edge.kind}\0${edge.from}\0${edge.to}`, edge); + } + return [...rows.values()].sort( + (left, right) => + compareRepositoryText(left.kind, right.kind) || + compareRepositoryText(left.from, right.from) || + compareRepositoryText(left.to, right.to), + ); +} + +function isInside(root: string, file: string): boolean { + const relative = path.relative(root, file); + return relative !== ".." && !relative.startsWith(`..${path.sep}`); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new Error("Gradle repository context cancelled"); + } +} diff --git a/packages/graph/src/repository/index.ts b/packages/graph/src/repository/index.ts new file mode 100644 index 00000000..2215b202 --- /dev/null +++ b/packages/graph/src/repository/index.ts @@ -0,0 +1,15 @@ +export * from "./cargoRepositoryContextProvider"; +export * from "./cmakeRepositoryContextProvider"; +export * from "./createRepositoryContextSession"; +export * from "./gradleRepositoryContextProvider"; +export * from "./IRepositoryContextProvider"; +export * from "./IRepositoryContextSession"; +export * from "./IResidentRepositoryContextSource"; +export * from "./pnpmRepositoryContextProvider"; +export * from "./REPOSITORY_CONTEXT_PROVIDERS"; +export * from "./repositoryContextFacts"; +export * from "./RepositoryContextProtocol"; +export * from "./createResidentRepositoryContextSource"; +export * from "./createResidentRepositoryContextMemorySource"; +export * from "./SamchonRepositoryContextMemory"; +export * from "./validateRepositoryContextProviders"; diff --git a/packages/graph/src/repository/parseGradleRepositoryContextModel.ts b/packages/graph/src/repository/parseGradleRepositoryContextModel.ts new file mode 100644 index 00000000..ee8c8ac1 --- /dev/null +++ b/packages/graph/src/repository/parseGradleRepositoryContextModel.ts @@ -0,0 +1,78 @@ +/** Parse the line-framed output produced by the packaged Gradle Tooling helper. */ +export function parseGradleRepositoryContextModel( + output: string, +): parseGradleRepositoryContextModel.IModel { + let version = ""; + const modules = new Map(); + for (const raw of output.split(/\r?\n/)) { + if (raw.trim() === "") continue; + const [kind, ...encoded] = raw.split("\t"); + const fields = encoded.map((value) => + Buffer.from(value, "base64url").toString("utf8"), + ); + if (kind === "V" && fields.length === 1) { + version = fields[0]!; + } else if (kind === "M" && fields.length === 3) { + modules.set(fields[0]!, { + path: fields[0]!, + name: fields[1]!, + directory: fields[2]!, + dependencies: [], + sources: [], + tasks: [], + }); + } else if (kind === "D" && fields.length === 2) { + requiredModule(modules, fields[0]!).dependencies.push(fields[1]!); + } else if (kind === "S" && fields.length === 4) { + requiredModule(modules, fields[0]!).sources.push({ + kind: fields[1]!, + directory: fields[2]!, + generated: fields[3] === "true", + }); + } else if (kind === "T" && fields.length === 3) { + requiredModule(modules, fields[0]!).tasks.push({ + path: fields[1]!, + name: fields[2]!, + }); + } else { + throw new Error("Gradle Tooling API helper returned a malformed model"); + } + } + if (version === "" || modules.size === 0) { + throw new Error("Gradle Tooling API helper returned an empty model"); + } + return { version, modules: [...modules.values()] }; +} + +export namespace parseGradleRepositoryContextModel { + export interface IModel { + version: string; + modules: IModule[]; + } + + export interface IModule { + path: string; + name: string; + directory: string; + dependencies: string[]; + sources: Array<{ + kind: string; + directory: string; + generated: boolean; + }>; + tasks: Array<{ path: string; name: string }>; + } +} + +function requiredModule( + modules: ReadonlyMap, + project: string, +): parseGradleRepositoryContextModel.IModule { + const found = modules.get(project); + if (found === undefined) { + throw new Error( + `Gradle Tooling API helper referenced unknown project ${project}`, + ); + } + return found; +} diff --git a/packages/graph/src/repository/pnpmRepositoryContextProvider.ts b/packages/graph/src/repository/pnpmRepositoryContextProvider.ts new file mode 100644 index 00000000..83d59053 --- /dev/null +++ b/packages/graph/src/repository/pnpmRepositoryContextProvider.ts @@ -0,0 +1,468 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import { ISamchonRepositoryContextDump } from "../structures"; +import { spawnableCommand } from "../utils/spawnableCommand"; +import { IRepositoryContextProvider } from "./IRepositoryContextProvider"; +import { createRepositoryContextSession } from "./createRepositoryContextSession"; +import { repositoryContextFacts } from "./repositoryContextFacts"; + +const { + compareRepositoryText, + repositoryContextCoverage, + repositoryContextEvidence, + repositoryContextFile, + repositoryContextId, + repositoryContextSource, + uniqueRepositorySources, +} = repositoryContextFacts; + +const PROVIDER = "pnpm-workspace"; +const ECOSYSTEM = "pnpm"; +const TARGET = "workspace"; + +interface IPnpmPackage { + name?: string; + version?: string; + path: string; + private?: boolean; + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; +} + +interface IPnpmDependency { + path?: string; +} + +interface IPackageManifest { + name?: string; + files?: string[]; + scripts?: Record; + main?: string; + module?: string; + types?: string; + typings?: string; + bin?: string | Record; + exports?: unknown; +} + +export const pnpmRepositoryContextProvider: IRepositoryContextProvider & { + collect: typeof collectPnpmRepositoryContext; +} = { + name: PROVIDER, + ecosystem: ECOSYSTEM, + authority: "tool-resolved", + families: [ + "contains", + "depends-on", + "source-of", + "entrypoint-of", + "joins-file", + ], + buildInputs: [ + "package.json", + "pnpm-workspace.yaml", + "pnpm-lock.yaml", + "pnpm-workspace.yml", + ], + detect: (root) => + fs.existsSync(path.join(root, "pnpm-workspace.yaml")) || + fs.existsSync(path.join(root, "pnpm-workspace.yml")), + open: (props) => + createRepositoryContextSession( + pnpmRepositoryContextProvider, + props, + collectPnpmRepositoryContext, + ), + collect: collectPnpmRepositoryContext, +}; + +function collectPnpmRepositoryContext( + props: IRepositoryContextProvider.IOpenProps & { signal?: AbortSignal }, + execute: typeof executePnpm = executePnpm, +): IRepositoryContextProvider.ICollection { + throwIfAborted(props.signal); + const packages = execute(props.root, props.env); + throwIfAborted(props.signal); + const byPath = new Map( + packages.map((entry) => [path.resolve(entry.path), entry]), + ); + const workspace = repositoryContextId( + ECOSYSTEM, + "workspace", + repositoryContextFile(props.root, props.root), + ); + const nodes: ISamchonRepositoryContextDump.INode[] = [ + { + id: workspace, + authority: "tool-resolved", + kind: "workspace", + name: path.basename(props.root), + ecosystem: ECOSYSTEM, + coordinate: ".", + configuration: "default", + external: false, + evidence: workspaceEvidence(props.root), + }, + ]; + const edges: ISamchonRepositoryContextDump.IEdge[] = []; + const sources: ISamchonRepositoryContextDump.ISource[] = [ + ...workspaceInputs(props.root).map((file) => + repositoryContextSource(props.root, file), + ), + ]; + const files = new Set(); + + const packageIds = new Map(); + for (const entry of packages.sort((left, right) => + compareRepositoryText(left.path, right.path), + )) { + const absolute = path.resolve(entry.path); + const coordinate = repositoryContextFile(props.root, absolute); + const manifestFile = path.join(absolute, "package.json"); + const manifest = readManifest(manifestFile); + const packageId = repositoryContextId( + ECOSYSTEM, + "package", + manifest.name ?? entry.name ?? coordinate, + ); + packageIds.set(absolute, packageId); + sources.push(repositoryContextSource(props.root, manifestFile)); + nodes.push({ + id: packageId, + authority: + manifest.name === undefined ? "tool-resolved" : "declared", + kind: "package", + name: manifest.name ?? entry.name ?? path.basename(absolute), + ecosystem: ECOSYSTEM, + coordinate, + configuration: "default", + external: false, + evidence: repositoryContextEvidence(props.root, manifestFile), + }); + edges.push({ + authority: "tool-resolved", + kind: "contains", + from: workspace, + to: packageId, + }); + appendManifestFacts( + props.root, + absolute, + packageId, + manifest, + nodes, + edges, + files, + ); + } + for (const parent of new Set( + packages + .map((entry) => path.resolve(entry.path)) + .filter((directory) => directory !== path.resolve(props.root)) + .map((directory) => path.dirname(directory)), + )) { + sources.push(repositoryContextSource(props.root, parent)); + } + + for (const entry of packages) { + const from = packageIds.get(path.resolve(entry.path))!; + for (const dependency of dependencyRows(entry)) { + if (dependency.path === undefined) continue; + const target = packageIds.get(path.resolve(dependency.path)); + if (target !== undefined) { + edges.push({ + authority: "tool-resolved", + kind: "depends-on", + from, + to: target, + }); + } + } + } + + const shard = { + key: `${PROVIDER}:workspace`, + target: TARGET, + nodes: nodes.sort((left, right) => + compareRepositoryText(left.id, right.id), + ), + edges: dedupeEdges(edges), + coverage: repositoryContextCoverage( + PROVIDER, + ECOSYSTEM, + TARGET, + ["contains", "depends-on", "entrypoint-of", "joins-file"], + ["source-of"], + ), + files: [...files].sort(compareRepositoryText), + sources: uniqueRepositorySources(sources), + }; + return { + producerSchemaVersion: 1, + tool: "pnpm", + toolVersion: detectPnpmVersion(props.root, props.env), + capabilities: [ + "workspace-members", + "resolved-local-dependencies", + "declared-entrypoints", + "declared-publication-roots", + ], + universe: `${ECOSYSTEM}:${shard.sources + .map((source) => `${source.file}:${source.digest}`) + .join("|")}`, + target: TARGET, + shards: [shard], + warnings: [ + "pnpm source-of coverage is partial: only package-manifest publication roots are declared facts.", + ], + }; +} + +function appendManifestFacts( + root: string, + packageRoot: string, + packageId: string, + manifest: IPackageManifest, + nodes: ISamchonRepositoryContextDump.INode[], + edges: ISamchonRepositoryContextDump.IEdge[], + files: Set, +): void { + const evidence = repositoryContextEvidence( + root, + path.join(packageRoot, "package.json"), + ); + for (const rootName of manifest.files ?? []) { + if (!isSimplePath(rootName)) continue; + const coordinate = `${repositoryContextFile(root, packageRoot)}/${rootName}`; + const generated = isGeneratedRoot(rootName); + const id = repositoryContextId( + ECOSYSTEM, + generated ? "generated-root" : "source-root", + coordinate, + ); + nodes.push({ + id, + authority: "declared", + kind: generated ? "generated-root" : "source-root", + name: rootName, + ecosystem: ECOSYSTEM, + coordinate, + configuration: "default", + external: false, + root: repositoryContextFile(root, path.resolve(packageRoot, rootName)), + evidence, + }); + edges.push( + { authority: "declared", kind: "contains", from: packageId, to: id }, + { authority: "declared", kind: "source-of", from: id, to: packageId }, + ); + } + for (const [name, target] of entrypoints(manifest)) { + const coordinate = `${repositoryContextFile(root, packageRoot)}:${name}`; + const id = repositoryContextId( + ECOSYSTEM, + "entrypoint", + coordinate, + ); + const file = repositoryContextFile(root, path.resolve(packageRoot, target)); + files.add(file); + nodes.push({ + id, + authority: "declared", + kind: "entrypoint", + name, + ecosystem: ECOSYSTEM, + coordinate, + configuration: "default", + external: false, + file, + evidence, + }); + edges.push( + { authority: "declared", kind: "contains", from: packageId, to: id }, + { + authority: "declared", + kind: "entrypoint-of", + from: id, + to: packageId, + }, + { authority: "declared", kind: "joins-file", from: id, to: file }, + ); + } + for (const name of Object.keys(manifest.scripts ?? {}).sort( + compareRepositoryText, + )) { + const coordinate = `${repositoryContextFile(root, packageRoot)}:${name}`; + const id = repositoryContextId(ECOSYSTEM, "task", coordinate); + nodes.push({ + id, + authority: "declared", + kind: "task", + name, + ecosystem: ECOSYSTEM, + coordinate, + configuration: "default", + external: false, + evidence, + }); + edges.push({ + authority: "declared", + kind: "contains", + from: packageId, + to: id, + }); + } +} + +function entrypoints(manifest: IPackageManifest): Array<[string, string]> { + const rows: Array<[string, string]> = []; + for (const [name, value] of [ + ["main", manifest.main], + ["module", manifest.module], + ["types", manifest.types ?? manifest.typings], + ] as const) { + if (typeof value === "string") rows.push([name, value]); + } + if (typeof manifest.bin === "string") rows.push(["bin", manifest.bin]); + else { + for (const [name, value] of Object.entries(manifest.bin ?? {})) { + rows.push([`bin:${name}`, value]); + } + } + collectExports(manifest.exports, "exports", rows); + return [...new Map(rows.map(([name, value]) => [`${name}\0${value}`, [name, value] as [string, string]])).values()].sort( + ([left], [right]) => compareRepositoryText(left, right), + ); +} + +function collectExports( + value: unknown, + name: string, + rows: Array<[string, string]>, +): void { + if (typeof value === "string") { + rows.push([name, value]); + } else if (value !== null && typeof value === "object") { + for (const [key, child] of Object.entries(value).sort(([left], [right]) => + compareRepositoryText(left, right), + )) { + collectExports(child, `${name}:${key}`, rows); + } + } +} + +function dependencyRows(entry: IPnpmPackage): IPnpmDependency[] { + return Object.values({ + ...(entry.dependencies ?? {}), + ...(entry.devDependencies ?? {}), + ...(entry.optionalDependencies ?? {}), + }); +} + +function readManifest(file: string): IPackageManifest { + return JSON.parse(fs.readFileSync(file, "utf8")) as IPackageManifest; +} + +function workspaceInputs(root: string): string[] { + return [ + "package.json", + "pnpm-workspace.yaml", + "pnpm-workspace.yml", + "pnpm-lock.yaml", + ].filter((file) => fs.existsSync(path.join(root, file))); +} + +function workspaceEvidence( + root: string, +): ISamchonRepositoryContextDump.IEvidence { + const file = workspaceInputs(root).find((entry) => + entry.startsWith("pnpm-workspace."), + ); + return repositoryContextEvidence(root, path.join(root, file ?? "package.json")); +} + +function executePnpm( + root: string, + env: NodeJS.ProcessEnv, +): IPnpmPackage[] { + /* c8 ignore next -- each coverage host has exactly one native shim suffix. */ + const command = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + const invocation = spawnableCommand( + command, + ["list", "-r", "--json", "--depth", "0"], + env, + ); + const result = spawnSync(invocation.command, invocation.args, { + cwd: root, + env, + encoding: "utf8", + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }); + if (result.status !== 0) { + /* c8 ignore start -- direct-spawn errors and silent nonzero exits are + * operating-system fallbacks; stderr failures are exercised here. */ + const failure = + result.stderr || result.error?.message || "unknown error"; + /* c8 ignore stop */ + throw new Error( + `pnpm repository context failed: ${failure.trim()}`, + ); + } + const parsed = JSON.parse(result.stdout) as IPnpmPackage[]; + if (!Array.isArray(parsed) || parsed.some((entry) => !entry.path)) { + throw new Error("pnpm repository context returned a malformed package list"); + } + return parsed; +} + +function detectPnpmVersion(root: string, env: NodeJS.ProcessEnv): string { + /* c8 ignore next -- each coverage host has exactly one native shim suffix. */ + const command = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + const invocation = spawnableCommand(command, ["--version"], env); + const result = spawnSync(invocation.command, invocation.args, { + cwd: root, + env, + encoding: "utf8", + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }); + return result.status === 0 ? result.stdout.trim() : ""; +} + +function dedupeEdges( + input: readonly ISamchonRepositoryContextDump.IEdge[], +): ISamchonRepositoryContextDump.IEdge[] { + const rows = new Map(); + for (const edge of input) { + rows.set(`${edge.kind}\0${edge.from}\0${edge.to}`, edge); + } + return [...rows.values()].sort( + (left, right) => + compareRepositoryText(left.kind, right.kind) || + compareRepositoryText(left.from, right.from) || + compareRepositoryText(left.to, right.to), + ); +} + +function isSimplePath(value: string): boolean { + return ( + value.trim() !== "" && + !value.includes("*") && + !value.startsWith("!") && + !path.isAbsolute(value) + ); +} + +function isGeneratedRoot(value: string): boolean { + return /^(?:lib|dist|build|out)(?:\/|$)/.test(value.replaceAll("\\", "/")); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new Error("pnpm repository context cancelled"); + } +} diff --git a/packages/graph/src/repository/repositoryContextFacts.ts b/packages/graph/src/repository/repositoryContextFacts.ts new file mode 100644 index 00000000..06174f24 --- /dev/null +++ b/packages/graph/src/repository/repositoryContextFacts.ts @@ -0,0 +1,124 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { ISamchonRepositoryContextDump } from "../structures"; +import { RepositoryContextRelationKind } from "../typings"; +import { RepositoryContextProtocol } from "./RepositoryContextProtocol"; + +/** Canonical repository-context identities, evidence and input digests. */ +export namespace repositoryContextFacts { + export function repositoryContextId( + ecosystem: string, + kind: ISamchonRepositoryContextDump.INode["kind"], + coordinate: string, + configuration = "default", +): string { + return `repository://${encodeURIComponent(ecosystem)}/${encodeURIComponent( + configuration, + )}/${encodeURIComponent(kind)}/${encodeURIComponent(coordinate)}`; +} + + export function repositoryContextCoverage( + provider: string, + ecosystem: string, + target: string, + complete: readonly RepositoryContextRelationKind[], + partial: readonly RepositoryContextRelationKind[] = [], +): ISamchonRepositoryContextDump.ICoverage[] { + return RepositoryContextProtocol.RELATION_KINDS.map((family) => ({ + provider, + ecosystem, + target, + family, + state: complete.includes(family) + ? "complete" + : partial.includes(family) + ? "partial" + : "unsupported", + })); +} + + export function repositoryContextSource( + root: string, + file: string, +): ISamchonRepositoryContextDump.ISource { + const absolute = path.resolve(root, file); + return { + file: repositoryContextFile(root, absolute), + digest: repositoryContextPathDigest(absolute), + }; +} + +/** Digest file bytes or one directory's immediate entry identities. */ + export function repositoryContextPathDigest(file: string): string { + try { + const stat = fs.statSync(file); + if (stat.isFile()) { + return RepositoryContextProtocol.digest(fs.readFileSync(file)); + } + if (stat.isDirectory()) { + return RepositoryContextProtocol.digest( + fs + .readdirSync(file, { withFileTypes: true }) + .map((entry) => ({ + name: entry.name, + kind: entry.isDirectory() + ? "directory" + : entry.isFile() + ? "file" + /* c8 ignore next -- special Dirents are platform-specific. */ + : "other", + })) + .sort((left, right) => compare(left.name, right.name)), + ); + } + } catch { + // The absent identity below is also used when a path moves mid-read. + } + return RepositoryContextProtocol.digest({ absent: true }); +} + + export function repositoryContextFile(root: string, file: string): string { + return path.relative(root, path.resolve(file)).replaceAll("\\", "/") || "."; +} + + export function repositoryContextEvidence( + root: string, + file: string, +): ISamchonRepositoryContextDump.IEvidence { + return { + file: repositoryContextFile(root, file), + startLine: 1, + startColumn: 1, + }; +} + + export function uniqueRepositorySources( + sources: readonly ISamchonRepositoryContextDump.ISource[], +): ISamchonRepositoryContextDump.ISource[] { + const unique = new Map(); + for (const source of sources) { + const prior = unique.get(source.file); + if (prior !== undefined && prior !== source.digest) { + throw new Error( + `repository context adapter: sources disagree about ${source.file}`, + ); + } + unique.set(source.file, source.digest); + } + return [...unique] + .sort(([left], [right]) => compare(left, right)) + .map(([file, digest]) => ({ file, digest })); +} + + export function compareRepositoryText( + left: string, + right: string, +): number { + return compare(left, right); +} + +function compare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} +} diff --git a/packages/graph/src/repository/validateRepositoryContextProviders.ts b/packages/graph/src/repository/validateRepositoryContextProviders.ts new file mode 100644 index 00000000..3b415d03 --- /dev/null +++ b/packages/graph/src/repository/validateRepositoryContextProviders.ts @@ -0,0 +1,22 @@ +import { IRepositoryContextProvider } from "./IRepositoryContextProvider"; + +/** Validate unique, non-empty repository-context provider contracts. */ +export function validateRepositoryContextProviders( + providers: readonly IRepositoryContextProvider[], +): readonly IRepositoryContextProvider[] { + const names = new Set(); + for (const provider of providers) { + if (provider.name.trim() === "" || names.has(provider.name)) { + throw new Error( + `repository context registry has an invalid provider name: ${provider.name}`, + ); + } + names.add(provider.name); + if (provider.ecosystem.trim() === "" || provider.families.length === 0) { + throw new Error( + `repository context registry provider ${provider.name} has an empty contract`, + ); + } + } + return Object.freeze([...providers]); +} diff --git a/packages/graph/src/structures/ISamchonGraphApplication.ts b/packages/graph/src/structures/ISamchonGraphApplication.ts index 97a81d6a..69e5e3f0 100644 --- a/packages/graph/src/structures/ISamchonGraphApplication.ts +++ b/packages/graph/src/structures/ISamchonGraphApplication.ts @@ -4,8 +4,12 @@ import { ISamchonGraphEscape } from "./ISamchonGraphEscape"; import { ISamchonGraphLookup } from "./ISamchonGraphLookup"; import { ISamchonGraphNext } from "./ISamchonGraphNext"; import { ISamchonGraphOverview } from "./ISamchonGraphOverview"; +import { ISamchonGraphCoverageSummary } from "./ISamchonGraphCoverageSummary"; +import { ISamchonGraphDump } from "./ISamchonGraphDump"; import { ISamchonGraphTour } from "./ISamchonGraphTour"; import { ISamchonGraphTrace } from "./ISamchonGraphTrace"; +import { ISamchonGraphTopology } from "./ISamchonGraphTopology"; +import { ISamchonGraphUnresolvedSummary } from "./ISamchonGraphUnresolvedSummary"; /** * ## Code Graph MCP @@ -42,6 +46,8 @@ import { ISamchonGraphTrace } from "./ISamchonGraphTrace"; * the classes that implement an interface, which is the one call that answers * "what actually implements this". * - `overview`: project layers and folder structure. + * - `topology`: workspace, package, target, task, source-root, entrypoint, and + * project-dependency orientation from declared or owning-tool models. * - `escape`: the answer is outside the graph (source body text, files outside * the indexed languages, exact search). * @@ -90,25 +96,24 @@ import { ISamchonGraphTrace } from "./ISamchonGraphTrace"; */ export interface ISamchonGraphApplication { /** - * Answer a __LANG__ question from this repository's own program index. + * Answer a __LANG__ question from the repository's program index. * - * The graph holds every symbol, call, type, decorator and test, each with its - * file and line, resolved from the source on disk now. Submit exactly one + * The graph returns proved facts with coverage and uncertainty. Submit one * request: * - * - `tour`: architecture, the runtime flow from the public API to the code that - * does the work, nearby paths, and the tests to read — a whole orientation - * in one call + * - `tour`: architecture, runtime flow, nearby paths, and tests * - `trace`: what a symbol calls, what calls it, or the path from A to B * - `details`: signatures, members, and what implements an interface * - `lookup`: where a named symbol is declared * - `entrypoints`: where execution starts, when the entry is unknown * - `overview`: the project's layers and folder structure + * - `topology`: repository workspaces, packages, roots, targets, tasks, and + * dependencies * * Every fact in a result is checked against the index before return, so no * fact needs verifying; for the ranked operations (`lookup`, `entrypoints`, - * `tour`), judge whether the shortlist covers your question. Read a file for - * what the graph does not carry: a body or the text inside a span. + * `tour`), judge whether the shortlist covers your question. Read source only + * for a body or span text. * * @param props Reasoning plus one graph request * @returns Matching `result` union member @@ -147,6 +152,7 @@ export namespace ISamchonGraphApplication { | ISamchonGraphDetails.IRequest | ISamchonGraphOverview.IRequest | ISamchonGraphTour.IRequest + | ISamchonGraphTopology.IRequest | ISamchonGraphEscape.IRequest; } @@ -178,6 +184,27 @@ export namespace ISamchonGraphApplication { */ audit: string; + /** + * Strict producer, authority, compiler and build-universe identity for the + * synchronized graph. Absent for `escape`, for `topology` whose facts come + * from the repository plane and carry their own provenance, and for a + * legacy or fallback-only dump with no strict producer. + */ + provenance?: ISamchonGraphDump.IProvenance[]; + + /** + * Machine-readable completeness for the relationship families relevant to + * this operation. Absent for `escape` and for `topology`, which reports + * its own relation coverage inside the result. + */ + coverage?: ISamchonGraphCoverageSummary; + + /** + * Bounded structured uncertainty for the same operation-scoped families. + * Absent for `escape` and for `topology`, whose plane publishes none. + */ + unresolved?: ISamchonGraphUnresolvedSummary; + /** What to do with `result`: answer, inspect one named request, or escape. */ next: ISamchonGraphNext; @@ -189,6 +216,7 @@ export namespace ISamchonGraphApplication { | ISamchonGraphDetails | ISamchonGraphOverview | ISamchonGraphTour + | ISamchonGraphTopology | ISamchonGraphEscape; } } diff --git a/packages/graph/src/structures/ISamchonGraphCoverage.ts b/packages/graph/src/structures/ISamchonGraphCoverage.ts new file mode 100644 index 00000000..62be181a --- /dev/null +++ b/packages/graph/src/structures/ISamchonGraphCoverage.ts @@ -0,0 +1,37 @@ +import { GraphEdgeKind, GraphLanguage } from "../typings"; + +/** + * What one producer can prove for one relationship family in one build target. + * + * Coverage is explicit because an empty edge list has two incompatible + * meanings: either the producer proved there are no such relationships, or it + * did not know how to collect them. Consumers must never infer which from the + * payload shape. + */ +export interface ISamchonGraphCoverage { + /** Stable registry identity of the producer that owns this row. */ + provider: string; + + /** Source language whose facts the row describes. */ + language: GraphLanguage; + + /** + * Producer-defined build target/configuration coordinate. + * + * This is not a display label. Equal values mean facts belong to the same + * semantic universe; incompatible source sets, features, triples or execution + * environments must use different values. + */ + target: string; + + /** Relationship family whose absence or uncertainty this row qualifies. */ + family: GraphEdgeKind; + + /** + * `complete` makes absence meaningful in the named universe; `partial` + * publishes proven facts while unresolved/excluded sites remain, whether or + * not a legacy or fallback producer can enumerate their exact locations; + * `unsupported` says the producer cannot prove the family. + */ + state: "complete" | "partial" | "unsupported"; +} diff --git a/packages/graph/src/structures/ISamchonGraphCoverageSummary.ts b/packages/graph/src/structures/ISamchonGraphCoverageSummary.ts new file mode 100644 index 00000000..8348d46f --- /dev/null +++ b/packages/graph/src/structures/ISamchonGraphCoverageSummary.ts @@ -0,0 +1,14 @@ +import { GraphEdgeKind } from "../typings"; +import { ISamchonGraphCoverage } from "./ISamchonGraphCoverage"; + +/** Operation-scoped machine-readable completeness returned beside MCP audit. */ +export interface ISamchonGraphCoverageSummary { + /** Version of this additive MCP trust contract. */ + schemaVersion: 1; + + /** Relationship families relevant to the selected operation. */ + families: GraphEdgeKind[]; + + /** Provider/target rows for those families. */ + rows: ISamchonGraphCoverage[]; +} diff --git a/packages/graph/src/structures/ISamchonGraphDump.ts b/packages/graph/src/structures/ISamchonGraphDump.ts index 3f9921b4..670a6db6 100644 --- a/packages/graph/src/structures/ISamchonGraphDump.ts +++ b/packages/graph/src/structures/ISamchonGraphDump.ts @@ -3,8 +3,10 @@ import { GraphLanguage } from "../typings/GraphLanguage"; import { GraphProviderAuthority } from "../typings/GraphProviderAuthority"; import { ISamchonGraphDiagnostic } from "./ISamchonGraphDiagnostic"; import { ISamchonGraphEdge } from "./ISamchonGraphEdge"; +import { ISamchonGraphCoverage } from "./ISamchonGraphCoverage"; import { ISamchonGraphNode } from "./ISamchonGraphNode"; import { ISamchonGraphSpan } from "./ISamchonGraphSpan"; +import { ISamchonGraphUnresolved } from "./ISamchonGraphUnresolved"; /** * The whole-graph export `samchon-graph dump` writes and the MCP server loads — @@ -39,9 +41,30 @@ export interface ISamchonGraphDump { /** Which indexing strategy produced the graph. */ indexer: "lsp" | "static" | "hybrid"; + /** + * Complete coordinator input generation used to fence code/topology joins. + * + * Absent only on dumps written before cross-plane generation fencing. + */ + generation?: { + input: string; + }; + /** What each strict provider proved about the slice it contributed, one row per provider, ordered by provider name so an unchanged checkout stays byte-identical. Absent when no strict provider served the build, and absent from dumps written before this field existed. Computation mode is deliberately not here: it belongs to one refresh rather than to the facts, so recording it would make two dumps of the same unedited checkout differ. */ provenance?: ISamchonGraphDump.IProvenance[]; + /** + * Exhaustive per-provider, language, target and relationship-family + * completeness rows. Absent only on dumps written before protocol version 1. + */ + coverage?: ISamchonGraphCoverage[]; + + /** + * Structured relationship sites that a producer could not resolve exactly. + * An empty list is meaningful only together with exhaustive coverage. + */ + unresolved?: ISamchonGraphUnresolved[]; + /** Every node the build recorded. */ nodes: ISamchonGraphDump.INode[]; diff --git a/packages/graph/src/structures/ISamchonGraphTopology.ts b/packages/graph/src/structures/ISamchonGraphTopology.ts new file mode 100644 index 00000000..c39c8cd8 --- /dev/null +++ b/packages/graph/src/structures/ISamchonGraphTopology.ts @@ -0,0 +1,65 @@ +import { RepositoryContextRelationKind } from "../typings"; +import { ISamchonRepositoryContextDump } from "./ISamchonRepositoryContextDump"; + +/** + * A bounded repository-topology projection kept separate from code semantics. + */ +export interface ISamchonGraphTopology { + /** Discriminator for repository topology. */ + type: "topology"; + + /** Version of this result contract. */ + schemaVersion: 1; + + /** Matching workspace, project, package, root, target, task and entry nodes. */ + nodes: ISamchonRepositoryContextDump.INode[]; + + /** Matching repository relations, including compatible file joins. */ + edges: ISamchonRepositoryContextDump.IEdge[]; + + /** Provider, authority, tool, universe and content claims for this result. */ + provenance: ISamchonRepositoryContextDump.IProvenance[]; + + /** Operation-scoped completeness for requested relation families. */ + coverage: ISamchonRepositoryContextDump.ICoverage[]; + + /** Topology publication generation that supplied this result. */ + generation: ISamchonRepositoryContextDump.IGeneration; + + /** Whether this result may join its file identities to the code generation. */ + join: ISamchonGraphTopology.IJoin; + + /** Whether a requested node, relation or file-join bound omitted facts. */ + truncated: boolean; +} + +export namespace ISamchonGraphTopology { + /** Ask for one bounded repository-context view. */ + export interface IRequest { + type: "topology"; + /** Optional exact node id, name or coordinate to orient around. */ + query?: string; + /** Optional relation families to retain. Empty or absent means all. */ + relations?: RepositoryContextRelationKind[]; + /** Maximum returned nodes. @default 100; maximum 500 */ + limit?: number; + + /** Maximum file joins returned after generation fencing. @default 50; maximum 500 */ + joinLimit?: number; + } + + /** Compatibility proof for file-level joins into the language graph. */ + export interface IJoin { + /** Whether file joins were admitted for this result. */ + state: "compatible" | "unavailable"; + + /** Repository-context input generation inspected by this result. */ + topologyInputGeneration: string; + + /** Stable code input generation fenced around the topology load. */ + codeInputGeneration?: string; + + /** Why joins are unavailable. */ + reason?: string; + } +} diff --git a/packages/graph/src/structures/ISamchonGraphUnresolved.ts b/packages/graph/src/structures/ISamchonGraphUnresolved.ts new file mode 100644 index 00000000..753c25aa --- /dev/null +++ b/packages/graph/src/structures/ISamchonGraphUnresolved.ts @@ -0,0 +1,44 @@ +import { GraphEdgeKind, GraphLanguage } from "../typings"; +import { ISamchonGraphEvidence } from "./ISamchonGraphEvidence"; + +/** + * One relationship site a semantic producer could not resolve exactly. + * + * Candidates remain evidence, not executed edges. In particular, a possible + * dynamic receiver target must not become `dispatches` until the selected + * universe proves it is the one runtime target. + */ +export interface ISamchonGraphUnresolved { + /** Stable registry identity of the producer that encountered the site. */ + provider: string; + + /** Source language of the unresolved expression or declaration. */ + language: GraphLanguage; + + /** Same semantic target/configuration coordinate used by coverage. */ + target: string; + + /** Exact build-universe digest in which this uncertainty was observed. */ + universe: string; + + /** Relationship family the producer could not settle. */ + family: GraphEdgeKind; + + /** Source location that grounds the uncertainty. */ + evidence: ISamchonGraphEvidence; + + /** Stable, closed reason understood by consumers. */ + reason: + | "dynamic" + | "reflection" + | "macro-or-generated" + | "conditional-build" + | "external-boundary" + | "analysis-error" + | "excluded-input" + | "identity-unstable" + | "provider-gap"; + + /** Compiler-proven possibilities, never guessed names. */ + candidates?: string[]; +} diff --git a/packages/graph/src/structures/ISamchonGraphUnresolvedSummary.ts b/packages/graph/src/structures/ISamchonGraphUnresolvedSummary.ts new file mode 100644 index 00000000..02392f0a --- /dev/null +++ b/packages/graph/src/structures/ISamchonGraphUnresolvedSummary.ts @@ -0,0 +1,22 @@ +import { ISamchonGraphUnresolved } from "./ISamchonGraphUnresolved"; + +/** Bounded, operation-scoped uncertainty returned beside MCP audit. */ +export interface ISamchonGraphUnresolvedSummary { + /** + * Number of relevant sites the producer explicitly published. + * + * Zero does not upgrade a `partial` coverage row to `complete`: legacy and + * fallback producers may know their analysis is partial without being able + * to enumerate the exact unresolved locations. + */ + count: number; + + /** Stable counts by machine-readable reason. */ + reasons: { + reason: ISamchonGraphUnresolved["reason"]; + count: number; + }[]; + + /** Deterministic first slice; `count` says whether more published sites exist. */ + examples: ISamchonGraphUnresolved[]; +} diff --git a/packages/graph/src/structures/ISamchonRepositoryContextDump.ts b/packages/graph/src/structures/ISamchonRepositoryContextDump.ts new file mode 100644 index 00000000..0650889d --- /dev/null +++ b/packages/graph/src/structures/ISamchonRepositoryContextDump.ts @@ -0,0 +1,119 @@ +import { + RepositoryContextAuthority, + RepositoryContextCoverageState, + RepositoryContextNodeKind, + RepositoryContextRelationKind, +} from "../typings"; + +/** + * A repository-topology snapshot kept beside, never inside, the language graph. + */ +export interface ISamchonRepositoryContextDump { + /** Absolute repository root whose owning tools were queried. */ + project: string; + + /** Version of this normalized repository-context body. */ + schemaVersion: 1; + + /** Complete source/configuration generation fenced around this snapshot. */ + inputGeneration: string; + + /** Monotonic resident publication identity. */ + generation: ISamchonRepositoryContextDump.IGeneration; + + /** One claim per contributing repository-context provider. */ + provenance: ISamchonRepositoryContextDump.IProvenance[]; + + /** Exhaustive family coverage for every published provider target. */ + coverage: ISamchonRepositoryContextDump.ICoverage[]; + + /** Every normalized workspace, project, target, root, task and entrypoint. */ + nodes: ISamchonRepositoryContextDump.INode[]; + + /** Every normalized repository-topology relation and file join. */ + edges: ISamchonRepositoryContextDump.IEdge[]; + + /** Normalized code-file identities that `joins-file` edges may target. */ + files: string[]; + + /** Exact declared/model inputs consumed by this snapshot. */ + sources: ISamchonRepositoryContextDump.ISource[]; + + /** Non-fatal unavailable-model or partial-coverage explanations. */ + warnings: string[]; +} + +export namespace ISamchonRepositoryContextDump { + export interface IGeneration { + sequence: number; + token: string; + shards: IShard[]; + contentDigest: string; + } + + export interface IShard { + key: string; + digest: string; + } + + export interface IProvenance { + provider: string; + ecosystem: string; + authority: RepositoryContextAuthority; + tool: string; + toolVersion: string; + schemaVersion: number; + protocolVersion: number; + universe: string; + manifest: string; + content: string; + capabilities: string[]; + } + + export interface ICoverage { + provider: string; + ecosystem: string; + target: string; + family: RepositoryContextRelationKind; + state: RepositoryContextCoverageState; + } + + export interface INode { + id: string; + /** Authority that establishes this exact node fact. */ + authority: RepositoryContextAuthority; + kind: RepositoryContextNodeKind; + name: string; + ecosystem: string; + coordinate: string; + configuration: string; + external: boolean; + /** Exact normalized source root whose current code files may be joined. */ + root?: string; + /** Exact normalized code file this node may join. */ + file?: string; + evidence?: IEvidence; + } + + export interface IEdge { + /** Authority that establishes this exact relation fact. */ + authority: RepositoryContextAuthority; + kind: RepositoryContextRelationKind; + from: string; + to: string; + evidence?: IEvidence; + } + + export interface IEvidence { + file: string; + startLine?: number; + startColumn?: number; + endLine?: number; + endColumn?: number; + } + + export interface ISource { + file: string; + digest: string; + } +} diff --git a/packages/graph/src/structures/index.ts b/packages/graph/src/structures/index.ts index cf6ecbbf..7fecf4d2 100644 --- a/packages/graph/src/structures/index.ts +++ b/packages/graph/src/structures/index.ts @@ -19,5 +19,11 @@ export * from "./ISamchonGraphOverview"; export * from "./ISamchonGraphSpan"; export * from "./ISamchonGraphTour"; export * from "./ISamchonGraphTrace"; +export * from "./ISamchonGraphTopology"; export * from "./ISamchonGraphApplication"; +export * from "./ISamchonGraphCoverage"; +export * from "./ISamchonGraphCoverageSummary"; +export * from "./ISamchonGraphUnresolved"; +export * from "./ISamchonGraphUnresolvedSummary"; export * from "./SamchonGraphNodeModifier"; +export * from "./ISamchonRepositoryContextDump"; diff --git a/packages/graph/src/typings/GRAPH_EDGE_KINDS.ts b/packages/graph/src/typings/GRAPH_EDGE_KINDS.ts new file mode 100644 index 00000000..ec5bac28 --- /dev/null +++ b/packages/graph/src/typings/GRAPH_EDGE_KINDS.ts @@ -0,0 +1,20 @@ +import { GraphEdgeKind } from "./GraphEdgeKind"; + +/** Every relationship family in deterministic protocol order. */ +export const GRAPH_EDGE_KINDS: readonly GraphEdgeKind[] = [ + "contains", + "exports", + "imports", + "calls", + "accesses", + "instantiates", + "type_ref", + "extends", + "implements", + "overrides", + "dispatches", + "decorates", + "renders", + "tests", + "references", +]; diff --git a/packages/graph/src/typings/RepositoryContextAuthority.ts b/packages/graph/src/typings/RepositoryContextAuthority.ts new file mode 100644 index 00000000..6fa9177e --- /dev/null +++ b/packages/graph/src/typings/RepositoryContextAuthority.ts @@ -0,0 +1,5 @@ +/** Evidence level for repository topology facts. */ +export type RepositoryContextAuthority = + | "tool-resolved" + | "declared" + | "inferred"; diff --git a/packages/graph/src/typings/RepositoryContextCoverageState.ts b/packages/graph/src/typings/RepositoryContextCoverageState.ts new file mode 100644 index 00000000..acb1a126 --- /dev/null +++ b/packages/graph/src/typings/RepositoryContextCoverageState.ts @@ -0,0 +1,5 @@ +/** Whether one repository-topology family is complete in a named universe. */ +export type RepositoryContextCoverageState = + | "complete" + | "partial" + | "unsupported"; diff --git a/packages/graph/src/typings/RepositoryContextNodeKind.ts b/packages/graph/src/typings/RepositoryContextNodeKind.ts new file mode 100644 index 00000000..f32ad094 --- /dev/null +++ b/packages/graph/src/typings/RepositoryContextNodeKind.ts @@ -0,0 +1,11 @@ +/** Version-one repository-context ontology. */ +export type RepositoryContextNodeKind = + | "workspace" + | "project" + | "package" + | "source-set" + | "source-root" + | "generated-root" + | "build-target" + | "task" + | "entrypoint"; diff --git a/packages/graph/src/typings/RepositoryContextRelationKind.ts b/packages/graph/src/typings/RepositoryContextRelationKind.ts new file mode 100644 index 00000000..1d217335 --- /dev/null +++ b/packages/graph/src/typings/RepositoryContextRelationKind.ts @@ -0,0 +1,10 @@ +/** Version-one repository-context relationship vocabulary. */ +export type RepositoryContextRelationKind = + | "contains" + | "depends-on" + | "source-of" + | "test-of" + | "produces" + | "invokes" + | "entrypoint-of" + | "joins-file"; diff --git a/packages/graph/src/typings/index.ts b/packages/graph/src/typings/index.ts index 1ba0ae29..c78de113 100644 --- a/packages/graph/src/typings/index.ts +++ b/packages/graph/src/typings/index.ts @@ -1,4 +1,9 @@ export * from "./GraphEdgeKind"; +export * from "./GRAPH_EDGE_KINDS"; export * from "./GraphLanguage"; export * from "./GraphNodeKind"; export * from "./GraphProviderAuthority"; +export * from "./RepositoryContextAuthority"; +export * from "./RepositoryContextCoverageState"; +export * from "./RepositoryContextNodeKind"; +export * from "./RepositoryContextRelationKind"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b9a21bf..cc56f174 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,11 +14,11 @@ catalogs: specifier: ^12.1.0 version: 12.1.0 '@ttsc/lint': - specifier: ^0.23.0 - version: 0.23.0 + specifier: ^0.25.0 + version: 0.25.0 '@ttsc/unplugin': - specifier: ^0.23.0 - version: 0.23.0 + specifier: ^0.25.0 + version: 0.25.0 '@typia/interface': specifier: ^13.2.0 version: 13.2.0 @@ -26,8 +26,8 @@ catalogs: specifier: ^13.2.0 version: 13.2.0 ttsc: - specifier: ^0.23.0 - version: 0.23.0 + specifier: ^0.25.0 + version: 0.25.0 typia: specifier: ^13.2.0 version: 13.2.0 @@ -73,7 +73,7 @@ importers: devDependencies: '@ttsc/lint': specifier: catalog:samchon - version: 0.23.0 + version: 0.25.0 packages/graph: dependencies: @@ -97,7 +97,7 @@ importers: version: 3.1.2 typia: specifier: catalog:samchon - version: 13.2.0(@types/node@22.20.0)(ttsc@0.23.0) + version: 13.2.0(@types/node@22.20.0)(ttsc@0.25.0) devDependencies: '@types/node': specifier: catalog:utils @@ -119,7 +119,7 @@ importers: version: 1.43.4(three@0.184.0) ttsc: specifier: catalog:samchon - version: 0.23.0 + version: 0.25.0 typescript: specifier: catalog:typescript version: 7.0.2 @@ -134,7 +134,7 @@ importers: version: 6.1.3 ttsc: specifier: catalog:samchon - version: 0.23.0 + version: 0.25.0 typescript: specifier: catalog:typescript version: 7.0.2 @@ -157,7 +157,7 @@ importers: devDependencies: ttsc: specifier: catalog:samchon - version: 0.23.0 + version: 0.25.0 typescript: specifier: catalog:typescript version: 7.0.2 @@ -178,7 +178,7 @@ importers: version: link:../../packages/graph-sitter '@ttsc/unplugin': specifier: catalog:samchon - version: 0.23.0(ttsc@0.23.0) + version: 0.25.0(ttsc@0.25.0) '@types/node': specifier: catalog:utils version: 22.20.0 @@ -581,46 +581,46 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@ttsc/darwin-arm64@0.23.0': - resolution: {integrity: sha512-JNkV0/qApeccnSNN+k96xo9+UphFQp5l6nDU16PhkspTckRzFb5P7wSaeCIHTM2xZ7gBhXDOnc3dxB+wfXm+2Q==} + '@ttsc/darwin-arm64@0.25.0': + resolution: {integrity: sha512-DrqbSBDRHPfCtYTWVuUMde4p+1dvfMHlBy430t3ckrQu2t8YzTzJRHKQiX858LaFjh3tyIF/1z55rjkXqyg/Ww==} cpu: [arm64] os: [darwin] - '@ttsc/darwin-x64@0.23.0': - resolution: {integrity: sha512-OvfO8U7p+7w862Hp2B0G/PRtQJDq6jH+zGjEKM+MCF0N667fAI3xgICzKF6tlhMJOUMD15GxwPDpaiIeilqOoQ==} + '@ttsc/darwin-x64@0.25.0': + resolution: {integrity: sha512-CX1B4HPxZsEQbMFTLAH2C2GHUl+a1Hs23Lht8iizqMNnWCTY5WG5CrvZhA6WYUwARzDLdvjdcvlECz4oHOwpgA==} cpu: [x64] os: [darwin] - '@ttsc/lint@0.23.0': - resolution: {integrity: sha512-9cJWMoW/VIJc3Ct+yA0/IEwsrGinelPesYLUfvnyclov5brctqFQF8Kl3zEgyVPRsyNWrxIVRW99hayjOfFHNw==} + '@ttsc/lint@0.25.0': + resolution: {integrity: sha512-Y0rGBnjvqBvxKNkgwfRcH1sOzRZJQIrol3TQdyLNawQJSj0eBP6OvpQWSecperX2LRDsJmw6k2sw2bZr8zTErw==} - '@ttsc/linux-arm64@0.23.0': - resolution: {integrity: sha512-eFS1k1Xtk3lEf4Pp7hTSTiXp4+u7mv6DgoxtiYDTwvWZ69s91cukIPZRaA0W06EUsWMe0J+nIqoc8/uJ6JpILQ==} + '@ttsc/linux-arm64@0.25.0': + resolution: {integrity: sha512-Vs3ELSCgHCPbEh2Mxgb4LjLTimK9f/MsGmDsy6nix5QIg+48l48F9qqpW452jdjTABWshd41CpnNqzWYIUqtZg==} cpu: [arm64] os: [linux] - '@ttsc/linux-arm@0.23.0': - resolution: {integrity: sha512-tHmejG6thGqSdQk1PUhzJ+C004cbrDgFmuGZ6bXsJj0zIuTikMnRJaw/gq8GMYI1jujtr3XV0cL+BaxS3oUz4A==} + '@ttsc/linux-arm@0.25.0': + resolution: {integrity: sha512-Dv4RANjL9/qwbBThXQ746uXasLw0okIc2YkjwbaNBUXGFN2Jusp7SQsXvfbNsr6Bbj/udyz3mmAAkCe3tnB9nw==} cpu: [arm] os: [linux] - '@ttsc/linux-x64@0.23.0': - resolution: {integrity: sha512-fcvWLP/f3vaXsaYTvwR03KPjWPhesGrJbJ0vpVSErnsbnjAYQkg7+3bdzdGhpUTILayd7TPX62weSAhG888FXA==} + '@ttsc/linux-x64@0.25.0': + resolution: {integrity: sha512-WkWQzPMJY2oo6SY+rtPjALvLNF3GiGV+POPhp96/R6NLLiwqEv2ou6noxp9v3bGAct6//nUNvzw+VfxRPi64Ww==} cpu: [x64] os: [linux] - '@ttsc/unplugin@0.23.0': - resolution: {integrity: sha512-hyLQlHHUp6eJjEOyLN1ARqqNHc2CkqBslNj+/xAUJp/yWRZ+kRBDumQde5tbywj/3VWZLU8SoeyuPlc53fbHqA==} + '@ttsc/unplugin@0.25.0': + resolution: {integrity: sha512-J9R3jWgafTbadeCupbGArAEnnRLxFgA4Y+g1v3IUjTtAX1Saq3wnjYvbYfyB/gzmAoPuQFb2/tMzIwatEsWQpQ==} peerDependencies: - ttsc: ^0.23.0 + ttsc: ^0.25.0 - '@ttsc/win32-arm64@0.23.0': - resolution: {integrity: sha512-4a7XBrjW/vNG/TdlCnZnGI3a/YandBM6t8XN/vdZGU1FYVxADkBed2egTKSC/PTEJbCtdnqrtsosZxmf73VXeg==} + '@ttsc/win32-arm64@0.25.0': + resolution: {integrity: sha512-mWHslRlxwLR5Sm7Pcq5/A/nH35kYnqrAqufkL8w32L6xoYbXHIQetdRBqf22djQNDqJ7xPqNBoq7ET22WO2e1g==} cpu: [arm64] os: [win32] - '@ttsc/win32-x64@0.23.0': - resolution: {integrity: sha512-sePUCdYny5/6fXHtxvoodumUWXv8lyGPTJleMAf6N5Dur+dW/bXEVVcEZmskTldzaHgX08KQKisKcmOLf1G5OA==} + '@ttsc/win32-x64@0.25.0': + resolution: {integrity: sha512-HLwKmIX3kZiL8CINhIzKFTttFOkU3Z9crXwzHvWH3ihsHn7UqlgvjWQgoLNXgyqZzBo1fAz3IDupLgTMUCHbWw==} cpu: [x64] os: [win32] @@ -1898,8 +1898,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - ttsc@0.23.0: - resolution: {integrity: sha512-OZmG/lrpi+neKmtYVeSpyVjWiyXA7c4rGwdRNWsRLEQglJ4yNc+Vp1IQemUEtPgowf/nlsNSe5H8bJ4KutJp2w==} + ttsc@0.25.0: + resolution: {integrity: sha512-f5FZg7TZb4BgMBAWfRWcd0KcLG5uVNNyzIMOHZLAk/0f4sQHUucNm6a6SDMd34WKhVsRmbCNk9BVoTvqytUpfQ==} engines: {node: '>=22.15.0'} hasBin: true @@ -2279,32 +2279,32 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@ttsc/darwin-arm64@0.23.0': + '@ttsc/darwin-arm64@0.25.0': optional: true - '@ttsc/darwin-x64@0.23.0': + '@ttsc/darwin-x64@0.25.0': optional: true - '@ttsc/lint@0.23.0': {} + '@ttsc/lint@0.25.0': {} - '@ttsc/linux-arm64@0.23.0': + '@ttsc/linux-arm64@0.25.0': optional: true - '@ttsc/linux-arm@0.23.0': + '@ttsc/linux-arm@0.25.0': optional: true - '@ttsc/linux-x64@0.23.0': + '@ttsc/linux-x64@0.25.0': optional: true - '@ttsc/unplugin@0.23.0(ttsc@0.23.0)': + '@ttsc/unplugin@0.25.0(ttsc@0.25.0)': dependencies: - ttsc: 0.23.0 + ttsc: 0.25.0 unplugin: 2.3.11 - '@ttsc/win32-arm64@0.23.0': + '@ttsc/win32-arm64@0.25.0': optional: true - '@ttsc/win32-x64@0.23.0': + '@ttsc/win32-x64@0.25.0': optional: true '@tweenjs/tween.js@23.1.3': {} @@ -3595,15 +3595,15 @@ snapshots: tslib@2.8.1: {} - ttsc@0.23.0: + ttsc@0.25.0: optionalDependencies: - '@ttsc/darwin-arm64': 0.23.0 - '@ttsc/darwin-x64': 0.23.0 - '@ttsc/linux-arm': 0.23.0 - '@ttsc/linux-arm64': 0.23.0 - '@ttsc/linux-x64': 0.23.0 - '@ttsc/win32-arm64': 0.23.0 - '@ttsc/win32-x64': 0.23.0 + '@ttsc/darwin-arm64': 0.25.0 + '@ttsc/darwin-x64': 0.25.0 + '@ttsc/linux-arm': 0.25.0 + '@ttsc/linux-arm64': 0.25.0 + '@ttsc/linux-x64': 0.25.0 + '@ttsc/win32-arm64': 0.25.0 + '@ttsc/win32-x64': 0.25.0 type-fest@0.21.3: {} @@ -3636,7 +3636,7 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 - typia@13.2.0(@types/node@22.20.0)(ttsc@0.23.0): + typia@13.2.0(@types/node@22.20.0)(ttsc@0.25.0): dependencies: '@standard-schema/spec': 1.1.0 '@typia/interface': 13.2.0 @@ -3646,7 +3646,7 @@ snapshots: randexp: 0.5.3 tinyglobby: 0.2.17 optionalDependencies: - ttsc: 0.23.0 + ttsc: 0.25.0 transitivePeerDependencies: - '@types/node' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fb306310..f4f7b461 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,7 +7,7 @@ catalogs: typescript: ^7.0.2 samchon: "@nestia/e2e": ^12.1.0 - ttsc: &ttsc ^0.23.0 + ttsc: &ttsc ^0.25.0 "@ttsc/lint": *ttsc "@ttsc/unplugin": *ttsc typia: &typia ^13.2.0 diff --git a/sidecars/go/analyze.go b/sidecars/go/analyze.go index 3c3f3e4b..c52086e7 100644 --- a/sidecars/go/analyze.go +++ b/sidecars/go/analyze.go @@ -240,8 +240,9 @@ func (c *collector) units(loaded []*packages.Package) ([]unit, error) { } // `go help packages`: "The go tool will ignore a directory named // testdata". scip-go enumerates by pattern and so never indexes one, - // while the checker reaches it through an ordinary import — gin's - // tests import .../testdata/protoexample, which is legal because + // while the checker reaches it through an ordinary import — a + // package may explicitly import a sibling under `testdata`, which is + // legal because // testdata is skipped by pattern matching and not by the importer. // // The two therefore disagreed about what the project is, and the @@ -486,7 +487,7 @@ func (c *collector) addObjectNode( symbol := objectSymbol(object, qualified) // Go allows many `func init()` in one package and forbids referring to any // of them, so every one shares a FullName and they all derive one identity. - // gin has several and the second one failed the build. + // A real package can have several; the second used to collide. // // Unlike the blank identifier this cannot be skipped: an init body runs and // what it calls are edges worth having. So it is disambiguated by where it @@ -872,7 +873,13 @@ func (c *collector) addEdge(value edge) { return } key := value.Kind + "\x00" + value.From + "\x00" + value.To - if _, exists := c.edges[key]; !exists { + // One semantic relation may be observed at several source sites and through + // several go/packages variants. The relation is unique by kind/endpoints, + // but retaining whichever evidence arrived first makes the published fact + // depend on package traversal order. Keep the canonical proof instead: + // evidence beats no evidence, then the earliest complete source span wins. + if existing, exists := c.edges[key]; !exists || + evidenceLess(value.Evidence, existing.Evidence) { c.edges[key] = value } } diff --git a/sidecars/go/main.go b/sidecars/go/main.go index 46078533..2a192756 100644 --- a/sidecars/go/main.go +++ b/sidecars/go/main.go @@ -128,12 +128,17 @@ func buildSnapshot( for _, key := range keys { universeParts = append(universeParts, key, normalizedEnvironmentValue(root, key, environment[key])) } - for index, moduleRoot := range roots { + for _, moduleRoot := range roots { identity, relativeErr := filepath.Rel(root, moduleRoot) if relativeErr != nil { return snapshot{}, fmt.Errorf("name Go module root %s: %w", moduleRoot, relativeErr) } - universeParts = append(universeParts, filepath.ToSlash(identity), artifacts[index].Digest) + // The SCIP index is derived corroboration, not an input coordinate. + // Its protobuf bytes can move while the selected Go build universe and + // the compiler-owned facts remain identical. Keep the module identity + // in the universe and validate the artifact below, but do not turn a + // navigation artifact digest into a public coverage target. + universeParts = append(universeParts, filepath.ToSlash(identity)) } for _, input := range inputs { body, readErr := os.ReadFile(input) diff --git a/sidecars/go/main_test.go b/sidecars/go/main_test.go index 5d65ba2a..48545f21 100644 --- a/sidecars/go/main_test.go +++ b/sidecars/go/main_test.go @@ -233,7 +233,9 @@ func TestScipBoundaryRejectsForeignAndMalformedArtifacts(t *testing.T) { t.Fatal(err) } filtered, err := validateScipIndex(root, withExternalBody) - if err != nil || len(filtered.Documents) != 1 || filtered.Digest != artifact.Digest { + if err != nil || + len(filtered.Documents) != 1 || + filtered.Documents[0] != artifact.Documents[0] { t.Fatalf("external SCIP cache document was not excluded canonically: artifact=%#v err=%v", filtered, err) } otherRoot := t.TempDir() @@ -244,9 +246,8 @@ func TestScipBoundaryRejectsForeignAndMalformedArtifacts(t *testing.T) { if err != nil { t.Fatal(err) } - otherArtifact, err := validateScipIndex(otherRoot, otherBody) - if err != nil || artifact.Digest != otherArtifact.Digest { - t.Error("SCIP digest retained checkout or invocation paths") + if _, err := validateScipIndex(otherRoot, otherBody); err != nil { + t.Error("equivalent SCIP artifact failed in another checkout") } invalid := []*scip.Index{ {}, @@ -269,7 +270,7 @@ func TestScipBoundaryRejectsForeignAndMalformedArtifacts(t *testing.T) { } } -func TestScipDigestAndUniverseIgnoreCheckoutLocations(t *testing.T) { +func TestScipBoundaryAndUniverseIgnoreCheckoutLocations(t *testing.T) { left := copyFixture(t) right := copyFixture(t) leftSnapshot, err := buildSnapshot(context.Background(), left, fixtureScipIndexer{}, fixtureEnvironment(left)) @@ -283,6 +284,18 @@ func TestScipDigestAndUniverseIgnoreCheckoutLocations(t *testing.T) { if leftSnapshot.Universe != rightSnapshot.Universe { t.Error("equivalent checkouts produced location-dependent Go universes") } + reorderedArtifactSnapshot, err := buildSnapshot( + context.Background(), + left, + fixtureScipIndexer{reverseDocuments: true}, + fixtureEnvironment(left), + ) + if err != nil { + t.Fatal(err) + } + if leftSnapshot.Universe != reorderedArtifactSnapshot.Universe { + t.Error("derived SCIP artifact ordering changed the Go build universe") + } if _, err := buildSnapshot( context.Background(), left, @@ -444,6 +457,42 @@ func TestSidecarRejectsConflictingNodesAndPreservesFileAuthorities(t *testing.T) } } +func TestSidecarCanonicalizesDuplicateEdgeEvidence(t *testing.T) { + earlier := edge{ + From: "from", To: "to", Kind: "calls", + Evidence: &evidence{ + File: "a.go", StartLine: 2, StartCol: 3, EndLine: 2, EndCol: 9, + }, + } + later := edge{ + From: "from", To: "to", Kind: "calls", + Evidence: &evidence{ + File: "z.go", StartLine: 8, StartCol: 1, EndLine: 8, EndCol: 7, + }, + } + withoutEvidence := edge{From: "from", To: "to", Kind: "calls"} + for name, values := range map[string][]edge{ + "later-first": {later, earlier, withoutEvidence}, + "earlier-first": {earlier, withoutEvidence, later}, + "no-evidence-first": {withoutEvidence, later, earlier}, + } { + t.Run(name, func(t *testing.T) { + graph := &collector{edges: map[string]edge{}} + for _, value := range values { + graph.addEdge(value) + } + if len(graph.edges) != 1 { + t.Fatalf("duplicate semantic relation produced %d edges", len(graph.edges)) + } + for _, actual := range graph.edges { + if !reflect.DeepEqual(actual, earlier) { + t.Fatalf("duplicate relation retained non-canonical evidence: %#v", actual) + } + } + }) + } +} + func TestSemanticIdentityMatchesTheSharedProviderV2Codec(t *testing.T) { if got, want := semanticID( "function", @@ -613,7 +662,8 @@ func TestProjectBoundaryAllowsOnlySharedSymlinkPrefixes(t *testing.T) { } type fixtureScipIndexer struct { - version string + version string + reverseDocuments bool } func (indexer fixtureScipIndexer) Version(context.Context) (string, error) { @@ -623,8 +673,10 @@ func (indexer fixtureScipIndexer) Version(context.Context) (string, error) { return "scip-go v0.2.7", nil } -func (fixtureScipIndexer) Index(_ context.Context, moduleRoot string) (scipArtifact, error) { - var parts []string +func (indexer fixtureScipIndexer) Index( + _ context.Context, + moduleRoot string, +) (scipArtifact, error) { var documents []string definitions := map[string]int{} err := filepath.WalkDir(moduleRoot, func(file string, entry os.DirEntry, walkErr error) error { @@ -632,15 +684,6 @@ func (fixtureScipIndexer) Index(_ context.Context, moduleRoot string) (scipArtif return walkErr } if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") { - body, err := os.ReadFile(file) - if err != nil { - return err - } - relative, err := filepath.Rel(moduleRoot, file) - if err != nil { - return err - } - parts = append(parts, filepath.ToSlash(relative), digestBytes(body)) documents = append(documents, file) definitions[pathKey(file)] = 1 } @@ -649,11 +692,13 @@ func (fixtureScipIndexer) Index(_ context.Context, moduleRoot string) (scipArtif if err != nil { return scipArtifact{}, err } - sort.Strings(parts) sort.Strings(documents) - return scipArtifact{ - Digest: digestStrings(parts...), Documents: documents, Definitions: definitions, - }, nil + if indexer.reverseDocuments { + for left, right := 0, len(documents)-1; left < right; left, right = left+1, right-1 { + documents[left], documents[right] = documents[right], documents[left] + } + } + return scipArtifact{Documents: documents, Definitions: definitions}, nil } type missingScipDocumentIndexer struct{ fixtureScipIndexer } diff --git a/sidecars/go/model.go b/sidecars/go/model.go index bfee2a3c..bb067569 100644 --- a/sidecars/go/model.go +++ b/sidecars/go/model.go @@ -172,6 +172,28 @@ func edgeKey(value edge) string { return value.Kind + "\x00" + value.From + "\x00" + value.To + "\x00" + position } +func evidenceLess(left, right *evidence) bool { + if left == nil { + return false + } + if right == nil { + return true + } + if left.File != right.File { + return left.File < right.File + } + if left.StartLine != right.StartLine { + return left.StartLine < right.StartLine + } + if left.StartCol != right.StartCol { + return left.StartCol < right.StartCol + } + if left.EndLine != right.EndLine { + return left.EndLine < right.EndLine + } + return left.EndCol < right.EndCol +} + func diagnosticKey(value diagnostic) string { return value.File + "\x00" + strconv.Itoa(value.Line) + "\x00" + strconv.Itoa(value.Column) + "\x00" + value.Message diff --git a/sidecars/go/scip.go b/sidecars/go/scip.go index 1536aab2..ea644bf8 100644 --- a/sidecars/go/scip.go +++ b/sidecars/go/scip.go @@ -19,7 +19,6 @@ import ( ) type scipArtifact struct { - Digest string Documents []string Definitions map[string]int } @@ -160,7 +159,6 @@ func validateScipIndex(moduleRoot string, body []byte) (scipArtifact, error) { seen := make(map[string]bool, len(index.Documents)) documents := make([]string, 0, len(index.Documents)) definitions := make(map[string]int, len(index.Documents)) - keptDocuments := make([]*scip.Document, 0, len(index.Documents)) for _, document := range index.Documents { relative := filepath.FromSlash(document.RelativePath) if document.RelativePath == "" { @@ -183,7 +181,6 @@ func validateScipIndex(moduleRoot string, body []byte) (scipArtifact, error) { return scipArtifact{}, fmt.Errorf("scip-go emitted a %s document: %s", document.Language, document.RelativePath) } absolute := filepath.Join(moduleRoot, cleaned) - keptDocuments = append(keptDocuments, document) documents = append(documents, absolute) for _, occurrence := range document.Occurrences { if occurrence.SymbolRoles&int32(scip.SymbolRole_Definition) != 0 { @@ -191,19 +188,9 @@ func validateScipIndex(moduleRoot string, body []byte) (scipArtifact, error) { } } } - canonical := proto.Clone(index).(*scip.Index) - canonical.Metadata.ProjectRoot = "" - canonical.Documents = keptDocuments - if canonical.Metadata.ToolInfo != nil { - canonical.Metadata.ToolInfo.Arguments = nil - } - canonicalBody, err := proto.MarshalOptions{Deterministic: true}.Marshal(canonical) - if err != nil { - return scipArtifact{}, fmt.Errorf("canonicalize scip-go artifact: %w", err) - } sort.Strings(documents) return scipArtifact{ - Digest: digestBytes(canonicalBody), Documents: documents, Definitions: definitions, + Documents: documents, Definitions: definitions, }, nil } diff --git a/sidecars/gradle/RepositoryContext.java b/sidecars/gradle/RepositoryContext.java new file mode 100644 index 00000000..aaad3653 --- /dev/null +++ b/sidecars/gradle/RepositoryContext.java @@ -0,0 +1,95 @@ +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.List; +import org.gradle.tooling.GradleConnector; +import org.gradle.tooling.ProjectConnection; +import org.gradle.tooling.model.build.BuildEnvironment; +import org.gradle.tooling.model.GradleProject; +import org.gradle.tooling.model.GradleTask; +import org.gradle.tooling.model.idea.IdeaContentRoot; +import org.gradle.tooling.model.idea.IdeaDependency; +import org.gradle.tooling.model.idea.IdeaModule; +import org.gradle.tooling.model.idea.IdeaModuleDependency; +import org.gradle.tooling.model.idea.IdeaProject; +import org.gradle.tooling.model.idea.IdeaSourceDirectory; + +/** + * Read-only Gradle Tooling API exporter used by the repository-context plane. + * + * The caller opts in because loading a Gradle model evaluates project build + * configuration. This helper never runs a task and reuses the wrapper-aware + * Tooling API connection/daemon selected by Gradle itself. + */ +public final class RepositoryContext { + public static void main(String[] args) { + if (args.length != 1) { + throw new IllegalArgumentException("usage: RepositoryContext.java "); + } + File root = new File(args[0]).getAbsoluteFile(); + GradleConnector connector = + GradleConnector.newConnector().forProjectDirectory(root); + try (ProjectConnection connection = connector.connect()) { + BuildEnvironment environment = connection.getModel(BuildEnvironment.class); + line("V", environment.getGradle().getGradleVersion()); + IdeaProject idea = connection.getModel(IdeaProject.class); + List modules = new ArrayList<>(idea.getModules()); + modules.sort(Comparator.comparing(module -> module.getGradleProject().getPath())); + for (IdeaModule module : modules) { + GradleProject project = module.getGradleProject(); + line("M", project.getPath(), module.getName(), project.getProjectDirectory().getPath()); + List tasks = new ArrayList<>(project.getTasks()); + tasks.sort(Comparator.comparing(GradleTask::getPath)); + for (GradleTask task : tasks) { + line("T", project.getPath(), task.getPath(), task.getName()); + } + for (IdeaContentRoot content : module.getContentRoots()) { + source(project.getPath(), "source", content.getSourceDirectories()); + source(project.getPath(), "test", content.getTestDirectories()); + source(project.getPath(), "resource", content.getResourceDirectories()); + source(project.getPath(), "test-resource", content.getTestResourceDirectories()); + } + List dependencies = + new ArrayList<>(module.getDependencies()); + for (IdeaDependency dependency : dependencies) { + if (dependency instanceof IdeaModuleDependency) { + IdeaModuleDependency projectDependency = (IdeaModuleDependency) dependency; + line("D", project.getPath(), projectDependency.getTargetModuleName()); + } + } + } + } + } + + private static void source( + String project, + String kind, + Iterable directories) { + List sorted = new ArrayList<>(); + for (IdeaSourceDirectory directory : directories) { + sorted.add(directory); + } + sorted.sort(Comparator.comparing(directory -> directory.getDirectory().getPath())); + for (IdeaSourceDirectory directory : sorted) { + line( + "S", + project, + kind, + directory.getDirectory().getPath(), + Boolean.toString(directory.isGenerated())); + } + } + + private static void line(String kind, String... fields) { + StringBuilder out = new StringBuilder(kind); + for (String field : fields) { + out.append('\t').append( + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(field.getBytes(StandardCharsets.UTF_8))); + } + System.out.println(out); + } +} diff --git a/tests/experiment/README.md b/tests/experiment/README.md index fb4ea828..87c97b53 100644 --- a/tests/experiment/README.md +++ b/tests/experiment/README.md @@ -2,7 +2,7 @@ This workspace runs real LSP smoke experiments outside the coverage-gated test suite. -Each language job installs the actual language server, clones a representative public project, builds a graph in `mode: "lsp"`, and fails if the result falls back to static indexing or produces no language symbols. +Each language job installs the actual language server, including any pinned producer fork declared by its catalog row, clones a representative public project, builds a graph in `mode: "lsp"`, and fails if the result loses its strict provenance or produces no language symbols. Use the workflow in `.github/workflows/experiment.yml` for the full matrix. diff --git a/tests/experiment/src/catalog.mjs b/tests/experiment/src/catalog.mjs index 69f3fa9c..9d384d0f 100644 --- a/tests/experiment/src/catalog.mjs +++ b/tests/experiment/src/catalog.mjs @@ -13,6 +13,12 @@ export const LANGUAGE_EXPERIMENTS = [ strictProvider: "ttscgraph", strictAuthority: "compiler", strictTool: "ttscgraph", + strictReleaseBoundary: { + version: "0.23.0", + warning: "legacy full dump", + reason: + "No published ttsc release implements graph snapshot protocol v1; 0.23.0 is provisioned to prove the explicit ttscserver fallback until the native producer ships.", + }, // The pinned starter has no construction expression. The lifecycle below // creates one and checks the real ttscgraph generation that contains it. semanticEdges: ["calls", "type_ref"], @@ -23,6 +29,8 @@ export const LANGUAGE_EXPERIMENTS = [ "diskDigests", "diagnostics", ], + minNodes: 1, + minEdges: 1, prepare: "npm ci --ignore-scripts", lifecycle: { sourceFile: "src/app.service.ts", @@ -75,11 +83,36 @@ export const LANGUAGE_EXPERIMENTS = [ language: "rust", repository: "https://github.com/tokio-rs/mini-redis.git", commit: "3d93b42bc363220f85af4fc9e1bebd35b588a4a3", - strictProvider: "rust-analyzer-scip", - strictAuthority: "semantic-index", - strictTool: "rust-analyzer", - requiredCapabilities: ["universe", "diskDigests"], - semanticEdges: ["contains", "references"], + strictProvider: "samchon-rust-analyzer-hir", + strictAuthority: "analyzer", + strictTool: "samchon-rust-analyzer", + producerRepository: "https://github.com/samchon/rust-analyzer.git", + producerCommit: "2850ecba80311bebd4cdaa9fedc5321533b5b1e7", + requiredCapabilities: [ + "coverage", + "diagnostics", + "diskDigests", + "incremental", + "sourceDigests", + "universe", + "unresolved", + "validatedConsumerCheckpoint", + ], + semanticEdges: [ + "contains", + "exports", + "imports", + "calls", + "accesses", + "instantiates", + "type_ref", + "extends", + "implements", + "overrides", + "decorates", + "tests", + "references", + ], crossFileEdge: "references", lifecycle: { sourceFile: "src/lib.rs", @@ -87,12 +120,17 @@ export const LANGUAGE_EXPERIMENTS = [ createFile: "examples/samchon_graph_experiment.rs", renamedFile: "examples/samchon_graph_experiment_renamed.rs", createText: - 'const samchonGraphExperiment: &str = "strict-lifecycle";\n\nfn main() { println!("{samchonGraphExperiment}"); }\n', + 'const samchonGraphExperiment: &str = "strict-lifecycle";\n\ntrait SamchonGraphParent {}\ntrait SamchonGraphChild: SamchonGraphParent {}\n\nfn main() { println!("{samchonGraphExperiment}"); }\n', createdSymbol: "samchonGraphExperiment", + createdEdge: { + kind: "extends", + from: "SamchonGraphChild", + to: "SamchonGraphParent", + }, buildFile: "Cargo.toml", - // Stock rust-analyzer's SCIP command recovers from malformed Rust and - // emits no diagnostics. A malformed Cargo manifest is the real strict - // failure boundary that the semantic-index authority can prove. + // A malformed Cargo manifest invalidates the producer's build universe, + // so the HIR snapshot must reject rather than mix an old database with + // new workspace inputs. failureFile: "Cargo.toml", failureSuffix: "\n[malformed", failurePolicy: "reject", @@ -102,39 +140,66 @@ export const LANGUAGE_EXPERIMENTS = [ language: "cpp", repository: "https://github.com/fmtlib/fmt.git", commit: "bcaa44d05579c75a83571821faee7acf6a9a0d55", - // Uncapped: scip-clang publishes a whole-workspace artifact and refuses a - // file cap, so a capped row is one it declines to serve. + // Uncapped: the native snapshot publishes a whole-compilation-database + // generation and refuses a file cap. // - // The compilation database is what scip-clang consumes and what a CMake - // project has to be configured to produce; nothing is compiled by this. + // The compilation database enumerates every native clangd graph view and + // is what a CMake project has to be configured to produce; preparation + // itself compiles nothing. prepare: "cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON", - strictProvider: "scip-clang", - strictAuthority: "semantic-index", - strictTool: "scip-clang", - requiredCapabilities: ["universe", "diskDigests"], - // Declarations only. scip-clang 0.4.0 writes range/symbol/roles on - // occurrences, no enclosing_range or enclosing_symbol, and no - // is_type_definition relationship. The common SCIP adapter therefore has - // no grounded origin or typed relationship for an edge. - semanticEdges: [], - semanticLimitation: - "scip-clang 0.4.0 emits no occurrence enclosing_range, SymbolInformation.enclosing_symbol, or type-definition relationship, so its semantic declarations carry no provable graph edge family", - // scip-clang 0.4.0's own CLI states both halves of this: `--deterministic` - // is documented as "Does not support deterministic work scheduling yet", - // and `--print-statistics-path` warns that "non-determinism may affect the - // number of files skipped by individual indexing jobs". The driver gives - // each well-behaved header to one translation unit, and which one wins - // depends on the schedule — so the file set moves, and the manifest with it. + strictProvider: "clangd-snapshot", + strictAuthority: "compiler", + strictTool: "samchon-clangd", + producerRepository: "https://github.com/samchon/llvm-project.git", + producerCommit: "ae904413566b54aca08e46ebee1769c110601e6b", + // A whole-compilation-database producer is not ready when it starts; it + // is ready when clangd has background-indexed every translation unit the + // database registers. The 180-second default expired on libuv with 62 of + // them still indexing, the routing layer fell back as it should, and the + // row lost the strict provenance it exists to prove. // - // Two ways of buying it back were tried and withdrawn. `--jobs=1` removed - // the variance by serializing the compiler and cost 39x on the redis - // corpus, which is not a trade a strict provider can make: the point of the - // lane is to be faster than the fallback. `--deterministic` alone then held - // this lane above forty-three minutes where it had run in under eleven, and - // its generations still did not reproduce. The limitation is declared - // instead. - regenerationLimitation: - "scip-clang 0.4.0 does not schedule its indexing jobs deterministically, so regenerating an unchanged project can skip a different set of headers; both the source manifest and the fact set can therefore move, because the manifest lists the files the producer reported", + // How long readiness actually takes has never been observed: no C or C++ + // row has ever reached it. The only datum is a lower bound — 180 seconds + // was not enough, with 62 units left — and the rate it implies puts the + // remainder near two more minutes. Ten is that with room, not a measured + // requirement. + // + // These are per-refresh ceilings, and the strict lifecycle issues nine + // refreshes, so they do not bound the row: nine cold waits would exceed + // the job timeout on their own and be killed by it without a diagnosis. + // What makes that remote rather than likely is that only the first + // refresh indexes from nothing; the rest are incremental against a warm + // database. The numbers are chosen so one cold index fits comfortably and + // a producer that never becomes ready still fails its row rather than + // hanging it — not so that every pathological path stays inside the job. + readyTimeoutMs: 600_000, + timeoutMs: 300_000, + requiredCapabilities: [ + "coverage", + "diagnostics", + "diskDigests", + "incremental", + "sourceDigests", + "universe", + "unresolved", + ], + // Native Clang occurrences and relations retain their enclosing symbols, + // exact ranges and TU/configuration identity in one compiler pass. + semanticEdges: [ + "contains", + "exports", + "imports", + "calls", + "accesses", + "type_ref", + "references", + ], + crossFileEdge: "references", + semanticLimitation: + "The native Clang lane retains exact TU/configuration facts, while calls, instantiation, exports, implements and dispatch stay explicitly partial and C/C++ have no decorates, renders or tests family.", + // Background jobs may finish in any order, but the native shard set, + // manifest and generation digest are canonical and publish only after all + // registered configurations agree on one complete source state. lifecycle: { sourceFile: "src/format.cc", editSuffix: "\n// samchon-graph lifecycle edit\n", @@ -150,39 +215,75 @@ export const LANGUAGE_EXPERIMENTS = [ compilationDatabase: "build/compile_commands.json", failureFile: "build/compile_commands.json", failureSuffix: "\n[ not json", - // A compilation database that will not parse makes scip-clang decline - // before publication. The resident records that reason and serves its - // documented fallback until the database is repaired. - failurePolicy: "fallback", - failureLimitation: - "a malformed compilation database makes scip-clang decline without provenance, so the resident publishes an explicitly warned generic/static fallback until the project input is repaired", + // A malformed compilation database invalidates the native universe, so + // the strict resident rejects publication until it is repaired. + failurePolicy: "reject", }, minNodes: 1, - minEdges: 0, + minEdges: 1, }, { language: "c", repository: "https://github.com/libuv/libuv.git", commit: "9d51562c10be60bc1126a3d71803b1038f4fbb7e", - // Uncapped: scip-clang publishes a whole-workspace artifact and refuses a - // file cap, so a capped row is one it declines to serve. + // Uncapped: the native snapshot publishes a whole-compilation-database + // generation and refuses a file cap. // - // The compilation database is what scip-clang consumes and what a CMake - // project has to be configured to produce; nothing is compiled by this. + // The compilation database enumerates every native clangd graph view and + // is what a CMake project has to be configured to produce; preparation + // itself compiles nothing. prepare: "cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON", - strictProvider: "scip-clang", - strictAuthority: "semantic-index", - strictTool: "scip-clang", - requiredCapabilities: ["universe", "diskDigests"], - // The same pinned producer contract as the C++ row: semantic declarations - // are real, but none of the common adapter's edge-grounding fields exists. - semanticEdges: [], + strictProvider: "clangd-snapshot", + strictAuthority: "compiler", + strictTool: "samchon-clangd", + producerRepository: "https://github.com/samchon/llvm-project.git", + producerCommit: "ae904413566b54aca08e46ebee1769c110601e6b", + // A whole-compilation-database producer is not ready when it starts; it + // is ready when clangd has background-indexed every translation unit the + // database registers. The 180-second default expired on libuv with 62 of + // them still indexing, the routing layer fell back as it should, and the + // row lost the strict provenance it exists to prove. + // + // How long readiness actually takes has never been observed: no C or C++ + // row has ever reached it. The only datum is a lower bound — 180 seconds + // was not enough, with 62 units left — and the rate it implies puts the + // remainder near two more minutes. Ten is that with room, not a measured + // requirement. + // + // These are per-refresh ceilings, and the strict lifecycle issues nine + // refreshes, so they do not bound the row: nine cold waits would exceed + // the job timeout on their own and be killed by it without a diagnosis. + // What makes that remote rather than likely is that only the first + // refresh indexes from nothing; the rest are incremental against a warm + // database. The numbers are chosen so one cold index fits comfortably and + // a producer that never becomes ready still fails its row rather than + // hanging it — not so that every pathological path stays inside the job. + readyTimeoutMs: 600_000, + timeoutMs: 300_000, + requiredCapabilities: [ + "coverage", + "diagnostics", + "diskDigests", + "incremental", + "sourceDigests", + "universe", + "unresolved", + ], + // The same pinned producer contract as the C++ row retains semantic + // enclosing symbols, exact ranges and TU/configuration identity. + semanticEdges: [ + "contains", + "exports", + "imports", + "calls", + "accesses", + "type_ref", + "references", + ], + crossFileEdge: "references", semanticLimitation: - "scip-clang 0.4.0 emits no occurrence enclosing_range, SymbolInformation.enclosing_symbol, or type-definition relationship, so its semantic declarations carry no provable graph edge family", - // The C and C++ slices share one producer, so they share its scheduling - // boundary as well; see the C++ row for the upstream wording. - regenerationLimitation: - "scip-clang 0.4.0 does not schedule its indexing jobs deterministically, so regenerating an unchanged project can skip a different set of headers; both the source manifest and the fact set can therefore move, because the manifest lists the files the producer reported", + "The native Clang lane retains exact TU/configuration facts, while calls, instantiation, exports, implements and dispatch stay explicitly partial and C/C++ have no decorates, renders or tests family.", + // C and C++ share the same atomic, canonical generation boundary. lifecycle: { sourceFile: "src/uv-common.c", editSuffix: "\n// samchon-graph lifecycle edit\n", @@ -198,13 +299,11 @@ export const LANGUAGE_EXPERIMENTS = [ compilationDatabase: "build/compile_commands.json", failureFile: "build/compile_commands.json", failureSuffix: "\n[ not json", - // The C and C++ slices share the same strict selection boundary. - failurePolicy: "fallback", - failureLimitation: - "a malformed compilation database makes scip-clang decline without provenance, so the resident publishes an explicitly warned generic/static fallback until the project input is repaired", + // The C and C++ slices share the same strict rejection boundary. + failurePolicy: "reject", }, minNodes: 1, - minEdges: 0, + minEdges: 1, }, { language: "java", @@ -399,12 +498,13 @@ export const LANGUAGE_EXPERIMENTS = [ // `_attemptParseFile`, which retries the parse six times, logs // `Config file "..." could not be parsed`, and returns `undefined`. // Configuration then falls through to defaults and the index is written - // and published with exit code 0. The bundle constructs no SCIP - // `Diagnostic` either, so neither `reject` nor `diagnostic` describes - // this producer; claiming one would pin the harness to a fiction. + // and published with exit code 0. On the pinned Click fixture the + // normalized source and fact planes remain unchanged; only the declared + // configuration coordinate moves. This is tolerated upstream behavior, + // not rejection, a diagnostic, or proof of a changed analyzed program. failurePolicy: "tolerated", failureLimitation: - "scip-python 0.6.6 recovers from a malformed pyproject.toml and publishes an index; a broken Python build configuration is not a fail-closed boundary for this producer", + "scip-python 0.6.6 recovers from a malformed pyproject.toml by falling back to Pyright defaults and exits successfully; on the pinned Click fixture its normalized source and fact planes remain unchanged, so a broken Python build configuration is neither rejected nor diagnosed", }, }, { diff --git a/tests/experiment/src/run-language.mjs b/tests/experiment/src/run-language.mjs index a02b8f30..a1425f41 100644 --- a/tests/experiment/src/run-language.mjs +++ b/tests/experiment/src/run-language.mjs @@ -1,4 +1,5 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { buildGraphDump } from "@samchon/graph"; @@ -18,13 +19,35 @@ import { import { runStrictLifecycle } from "./strict-lifecycle.mjs"; activateProvisionedTools(); + +// A host memory trace, because four C/C++ runs have now ended with the runner +// reporting a shutdown while the producer was indexing, and four explanations +// for it have been wrong: build serialization, the job bound, host preemption +// of long jobs, and polling pressure. Each was retired by a measurement, and +// the last one by a run that died sooner with ninety times fewer requests. +// +// A shutdown with no diagnostic is what an out-of-memory kill of the runner +// agent looks like from inside the job, so the one thing never observed is +// what the host had left. This prints it rather than reasoning about it. It is +// free, it says nothing about any lane that does not fail, and it is the +// difference between a fifth theory and evidence. +const memoryTrace = setInterval(() => { + const free = Math.round(os.freemem() / (1024 * 1024)); + const total = Math.round(os.totalmem() / (1024 * 1024)); + console.log( + `experiment host memory: ${String(free)} MiB free of ${String(total)} MiB`, + ); +}, 10_000); +memoryTrace.unref?.(); const args = parseArgs(process.argv.slice(2)); const experiment = findExperiment(args.language); const pinned = cloneRepository(experiment, { refresh: args.refresh === "true" }); // Some language servers need the checkout prepared before they can boot — // ruby-lsp, for one, composes a bundle from the project's Gemfile. That runs in // a copy for both lanes, so the clone keeps proving which revision was measured. -const strict = experiment.strictProvider !== undefined; +const strictDeclared = experiment.strictProvider !== undefined; +const releaseBoundary = experiment.strictReleaseBoundary; +const strict = strictDeclared && releaseBoundary === undefined; // Read before anything is indexed: a row that cannot be satisfied should // fail in seconds rather than after a full real-server build. @@ -33,7 +56,7 @@ const strict = experiment.strictProvider !== undefined; // resolved this" and "an index built from a navigation skeleton reports this" // are different grades of evidence, and a row that does not say which one it // expects cannot detect a provider that silently changed grade. -if (strict) { +if (strictDeclared) { for (const field of [ "strictAuthority", "strictTool", @@ -84,6 +107,18 @@ if (strict) { `${experiment.language}: a strict row that accepts an unreproducible regeneration must state why`, ); } + if (releaseBoundary !== undefined) { + for (const field of ["version", "warning", "reason"]) { + if ( + typeof releaseBoundary[field] !== "string" || + releaseBoundary[field].trim() === "" + ) { + throw new Error( + `${experiment.language}: a strict release boundary must state ${field}`, + ); + } + } + } } else if ( // A language without a strict row has to say why it has none. Otherwise the // catalog cannot distinguish a producer that was investigated and found @@ -119,7 +154,13 @@ if (strict) { mode: "lsp", languages: [experiment.language], maxFiles: experiment.maxFiles, - lspReferenceLimit: experiment.referenceLimit ?? 250, + // A published-release boundary must launch the registered provider once + // so the experiment proves its exact incompatibility before observing the + // ordinary fallback. The default cap deliberately disables whole-project + // providers and would turn that proof into a selection refusal. + ...(releaseBoundary === undefined + ? { lspReferenceLimit: experiment.referenceLimit ?? 250 } + : {}), lspTimeoutMs: experiment.timeoutMs ?? 60_000, lspReadyTimeoutMs: experiment.readyTimeoutMs ?? 180_000, lspWarmupTimeoutMs: experiment.warmupTimeoutMs ?? 180_000, @@ -127,6 +168,25 @@ if (strict) { elapsedMs = Math.round(performance.now() - started); } const warnings = dump.warnings ?? []; +const declaredProvenance = strictDeclared + ? dump.provenance?.find( + (row) => row.provider === experiment.strictProvider, + ) + : undefined; + +if ( + releaseBoundary !== undefined && + (declaredProvenance !== undefined || + !warnings.some( + (warning) => + warning.includes(experiment.strictProvider) && + warning.includes(releaseBoundary.warning), + )) +) { + throw new Error( + `${experiment.language}: published ${releaseBoundary.version} did not prove the declared strict release boundary: ${warnings.join("; ")}`, + ); +} if (dump.indexer === "static") { throw new Error(`${experiment.language}: expected real LSP indexing, got static fallback: ${warnings.join("; ")}`); @@ -141,9 +201,7 @@ const minEdges = experiment.minEdges ?? 0; if (!strict && dump.edges.length < minEdges) { throw new Error(`${experiment.language}: expected at least ${minEdges} relationship edges, got ${dump.edges.length}`); } -const provenance = strict - ? dump.provenance?.find((row) => row.provider === experiment.strictProvider) - : undefined; +const provenance = strict ? declaredProvenance : undefined; if (strict && provenance === undefined) { throw new Error( `${experiment.language}: strict provider ${experiment.strictProvider} did not publish provenance: ${warnings.join("; ")}`, @@ -204,7 +262,7 @@ const edgeKindCounts = Object.fromEntries( // required this exact edge in both generations; count that evidence instead of // pre-editing the pinned baseline merely to make the final cold dump contain it. const lifecycleCreatedEdge = experiment.lifecycle?.createdEdge; -for (const kind of experiment.semanticEdges ?? []) { +for (const kind of strict ? experiment.semanticEdges ?? [] : []) { if ( (edgeKindCounts[kind] ?? 0) === 0 && lifecycleCreatedEdge?.kind !== kind @@ -294,6 +352,7 @@ const result = { edgeCount: dump.edges.length, diagnosticCount: dump.diagnostics?.length ?? 0, strictProvider: experiment.strictProvider, + strictReleaseBoundary: releaseBoundary, provenance, edgeKindCounts, semanticLimitation: experiment.semanticLimitation, diff --git a/tests/experiment/src/setup-language.mjs b/tests/experiment/src/setup-language.mjs index f3927f8a..9595023e 100644 --- a/tests/experiment/src/setup-language.mjs +++ b/tests/experiment/src/setup-language.mjs @@ -399,6 +399,249 @@ const installScipClang = () => "06fd18c576f979a726c651594644ec4a35db4f471f2160b3f72eb89fa6001784", }); +/** + * Accept an already-installed pinned producer, or report that there is none. + * + * Deliberately total: any missing file, any unreadable resource tree, any + * version string that does not name the pinned commit, and any error at all + * means "build it". Reuse is an optimisation, so it may only ever be taken + * when the evidence for it is complete. + */ +const installedClangGraphProducer = () => { + try { + const installed = path.join(binRoot, "samchon-clangd"); + const alias = path.join(binRoot, "clangd"); + if ( + !fs.statSync(installed, { throwIfNoEntry: false })?.isFile() || + !fs.statSync(alias, { throwIfNoEntry: false })?.isFile() + ) { + return false; + } + const resources = path.join(toolsRoot, "lib", "clang"); + const versions = fs + .readdirSync(resources, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + fs + .statSync(path.join(resources, entry.name, "include", "stddef.h"), { + throwIfNoEntry: false, + }) + ?.isFile(), + ); + if (versions.length !== 1) return false; + for (const binary of [installed, alias]) { + const reported = run(binary, ["--version"], { stdio: "pipe" }); + if (!String(reported.stdout).includes(experiment.producerCommit)) { + return false; + } + } + } catch { + return false; + } + record({ + tool: "samchon-clangd", + version: experiment.producerCommit, + source: `${experiment.producerRepository}@${experiment.producerCommit}`, + digest: `git:${experiment.producerCommit}`, + }); + record({ + tool: "clangd", + version: experiment.producerCommit, + source: "alias of samchon-clangd", + digest: `git:${experiment.producerCommit}`, + }); + return true; +}; + +const installClangGraphProducer = () => { + if ( + typeof experiment.producerRepository !== "string" || + typeof experiment.producerCommit !== "string" + ) { + throw new Error( + `${experiment.language}: native Clang setup requires an exact producer repository and commit`, + ); + } + // The producer is a pinned commit, so its binary is a pure function of that + // commit and this toolchain. Rebuilding it on every push was the actual + // waste: roughly two CPU-hours per workflow to reproduce bytes that cannot + // have changed. A restored install is therefore reused rather than rebuilt — + // but only after it says, itself, that it is the pinned producer. A cache is + // untrusted input, and the same `--version` check the fresh build has to + // pass is what admits a restored one, so a stale or foreign artifact fails + // closed here instead of quietly indexing a corpus with the wrong compiler. + if (installedClangGraphProducer()) return; + const source = path.join(toolsRoot, "samchon-clangd-source"); + const build = path.join(source, "build"); + fs.rmSync(source, { force: true, recursive: true }); + ensureDir(source); + run("git", ["init", "--quiet"], { cwd: source }); + run("git", ["remote", "add", "origin", experiment.producerRepository], { + cwd: source, + }); + run( + "git", + ["fetch", "--depth=1", "origin", experiment.producerCommit], + { cwd: source }, + ); + run("git", ["checkout", "--detach", "FETCH_HEAD"], { cwd: source }); + const revision = String( + run("git", ["rev-parse", "HEAD"], { + cwd: source, + stdio: "pipe", + }).stdout, + ).trim(); + if (revision !== experiment.producerCommit) { + throw new Error( + `${experiment.language}: checked out native Clang ${revision}, expected ${experiment.producerCommit}`, + ); + } + run("cmake", [ + "-S", + path.join(source, "llvm"), + "-B", + build, + "-G", + "Ninja", + "-DCMAKE_BUILD_TYPE=Release", + "-DCMAKE_C_COMPILER=clang", + "-DCMAKE_CXX_COMPILER=clang++", + "-DLLVM_ENABLE_PROJECTS=clang;clang-tools-extra", + "-DLLVM_TARGETS_TO_BUILD=Native", + "-DLLVM_ENABLE_ASSERTIONS=ON", + "-DLLVM_INCLUDE_TESTS=OFF", + "-DCLANG_INCLUDE_TESTS=OFF", + "-DLLVM_INCLUDE_BENCHMARKS=OFF", + "-DLLVM_INCLUDE_EXAMPLES=OFF", + `-DLLVM_FORCE_VC_REVISION=${experiment.producerCommit}`, + `-DLLVM_FORCE_VC_REPOSITORY=${experiment.producerRepository}`, + ]); + // Build with the machine, not with a number. Note what that is and is not + // claiming, because two earlier versions of this comment claimed more. + // + // Every recorded build of this producer, all at the advertised job count + // except the first: 2,431 of 3,125 steps in 85 minutes and killed unfinished + // at a fixed `2`; 2,431 steps in 81.2 minutes; a complete build in 56.1; a + // complete build in 107. The last two are the same commit and the same job + // count, in one workflow, on two runners. Hosted-runner performance varies + // by roughly a factor of two, which swamps the difference this line makes + // and leaves no clean two-against-four comparison in the data at all. + // + // So the reason for sizing by the machine is the principle, not a measured + // speedup: a constant that leaves half a runner idle is wrong wherever it + // runs, and the effect size here is unmeasured. An earlier comment reported + // "roughly half" and another "barely five percent"; both read a difference + // out of numbers that could not support one. + // + // Also bounded by installed memory, which is a machine-class bound and not + // an out-of-memory guard — worth being exact about, because the two are easy + // to confuse and only the first is what this computes. It reads total rather + // than free memory, so it says "this machine should not run more than N + // concurrent compiles", not "this machine has room right now". It bounds + // compile concurrency only; the `clangd` link is a single build edge that + // runs whatever this number is, and LLVM's own controls for that + // (`LLVM_PARALLEL_LINK_JOBS` and friends) are deliberately not set here + // because the runs that reached the link reached it without trouble, so + // there is nothing yet to size them against. Two GiB per compile + // job is this repository's figure, chosen as a conventional one; it is not + // quoted from LLVM. + // + // Logged because it is otherwise invisible. Ninja does not print its job + // count and `run` does not echo argv, so a machine whose memory quietly + // halves the count would look exactly like a slow build, which is the + // confusion that cost this lane two CI runs already. + const jobs = Math.max( + 1, + Math.min( + os.availableParallelism(), + Math.floor(os.totalmem() / (2 * 1024 * 1024 * 1024)), + ), + ); + console.log( + `${experiment.language}: building the pinned Clang producer with ${String(jobs)} jobs ` + + `(cores ${String(os.availableParallelism())}, ` + + `memory ${String(Math.round(os.totalmem() / (1024 * 1024 * 1024)))} GiB)`, + ); + run("cmake", [ + "--build", + build, + "--parallel", + String(jobs), + "--target", + "clangd", + ]); + const binary = path.join(build, "bin", "clangd"); + const version = String( + run(binary, ["--version"], { stdio: "pipe" }).stdout, + ); + if (!version.includes(experiment.producerCommit)) { + throw new Error( + `${experiment.language}: native Clang version omits ${experiment.producerCommit}:\n${version}`, + ); + } + const builtResources = path.join(build, "lib", "clang"); + const resourceVersions = fs + .readdirSync(builtResources, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + fs.statSync( + path.join(builtResources, entry.name, "include"), + { throwIfNoEntry: false }, + )?.isDirectory(), + ) + .map((entry) => entry.name); + if (resourceVersions.length !== 1) { + throw new Error( + `${experiment.language}: native Clang produced ${resourceVersions.length} resource-header trees`, + ); + } + const installedResources = path.join(toolsRoot, "lib", "clang"); + fs.rmSync(installedResources, { force: true, recursive: true }); + ensureDir(path.dirname(installedResources)); + fs.cpSync(builtResources, installedResources, { recursive: true }); + const installedStddef = path.join( + installedResources, + resourceVersions[0], + "include", + "stddef.h", + ); + if (!fs.statSync(installedStddef, { throwIfNoEntry: false })?.isFile()) { + throw new Error( + `${experiment.language}: native Clang resource headers were not installed at ${installedStddef}`, + ); + } + for (const command of ["samchon-clangd", "clangd"]) { + const link = path.join(binRoot, command); + fs.rmSync(link, { force: true }); + fs.linkSync(binary, link); + } + const installedVersion = String( + run(path.join(binRoot, "samchon-clangd"), ["--version"], { + stdio: "pipe", + }).stdout, + ); + if (!installedVersion.includes(experiment.producerCommit)) { + throw new Error( + `${experiment.language}: installed native Clang omits ${experiment.producerCommit}:\n${installedVersion}`, + ); + } + record({ + tool: "samchon-clangd", + version: experiment.producerCommit, + source: `${experiment.producerRepository}@${experiment.producerCommit}`, + digest: `git:${experiment.producerCommit}`, + }); + record({ + tool: "clangd", + version: experiment.producerCommit, + source: "alias of samchon-clangd", + digest: `git:${experiment.producerCommit}`, + }); + fs.rmSync(source, { force: true, recursive: true }); +}; + // The published tarball is a webpack bundle whose only runtime `require`s are // Node built-ins, so extracting the integrity-verified archive installs exactly // the bytes the digest covers. `npm install` would instead resolve the package's @@ -467,7 +710,12 @@ switch (experiment.language) { // so the PATH fallback is the only route — and the binary lives inside the // platform package rather than in `@ttsc/graph`, whose npm `bin` publishes // `ttsc-graph` and not this. - const ttscVersion = "0.22.0"; + const ttscVersion = experiment.strictReleaseBoundary?.version; + if (ttscVersion === undefined) { + throw new Error( + "typescript: the published ttsc setup must name its strict release boundary", + ); + } shell(`npm install -g @ttsc/linux-x64@${ttscVersion}`); const globalRoot = shell("npm root -g", { stdio: ["ignore", "pipe", "inherit"], @@ -542,36 +790,111 @@ switch (experiment.language) { }); break; } - case "rust": + case "rust": { // The installer script comes through the same hardened seam as every // other fetch. Piping curl into `sh` would be the one shape curl's own // manual tells us not to retry: a retried mid-body transfer is not // rewound in a pipe, so `sh` could read the partial prefix twice. await downloadFile("https://sh.rustup.rs", path.join(toolsRoot, "rustup-init.sh")); - shell(`sh "${path.join(toolsRoot, "rustup-init.sh")}" -y --profile minimal`); - appendGithubPath(path.join(os.homedir(), ".cargo", "bin")); - shell(`${path.join(os.homedir(), ".cargo", "bin", "rustup")} component add rust-analyzer`); + shell( + `sh "${path.join(toolsRoot, "rustup-init.sh")}" -y --profile minimal --default-toolchain 1.95.0`, + ); + const cargoBin = path.join(os.homedir(), ".cargo", "bin"); + appendGithubPath(cargoBin); + run( + path.join( + cargoBin, + process.platform === "win32" ? "rustup.exe" : "rustup", + ), + [ + "component", + "add", + "rust-src", + "--toolchain", + "1.95.0", + ], + ); + record({ + tool: "rust-toolchain", + version: "1.95.0", + source: "rustup profile minimal with rust-src", + digest: "rustup:1.95.0", + }); + const producerRoot = path.join(toolsRoot, "samchon-rust-analyzer-source"); + fs.rmSync(producerRoot, { force: true, recursive: true }); + ensureDir(producerRoot); + run("git", ["init"], { cwd: producerRoot }); + run("git", ["remote", "add", "origin", experiment.producerRepository], { + cwd: producerRoot, + }); + run( + "git", + ["fetch", "--depth=1", "origin", experiment.producerCommit], + { cwd: producerRoot }, + ); + run("git", ["checkout", "--detach", "FETCH_HEAD"], { + cwd: producerRoot, + }); + const producerHead = String( + run("git", ["rev-parse", "HEAD"], { + cwd: producerRoot, + stdio: "pipe", + }).stdout, + ).trim(); + if (producerHead !== experiment.producerCommit) { + throw new Error( + `rust producer checkout is ${producerHead}, not ${experiment.producerCommit}`, + ); + } + run( + path.join(cargoBin, process.platform === "win32" ? "cargo.exe" : "cargo"), + ["build", "--locked", "--release", "-p", "rust-analyzer"], + { cwd: producerRoot }, + ); + const producerBinary = path.join( + producerRoot, + "target", + "release", + process.platform === "win32" ? "rust-analyzer.exe" : "rust-analyzer", + ); + for (const command of ["samchon-rust-analyzer", "rust-analyzer"]) { + const link = path.join( + binRoot, + `${command}${process.platform === "win32" ? ".exe" : ""}`, + ); + fs.rmSync(link, { force: true }); + fs.linkSync(producerBinary, link); + } + recordProvisionedEnvironment( + "SAMCHON_GRAPH_RUST_ANALYZER_HIR", + path.join( + binRoot, + `samchon-rust-analyzer${process.platform === "win32" ? ".exe" : ""}`, + ), + ); + record({ + tool: "samchon-rust-analyzer", + version: experiment.producerCommit, + source: `${experiment.producerRepository}@${experiment.producerCommit}`, + digest: `git:${experiment.producerCommit}`, + }); record({ tool: "rust-analyzer", - version: "unpinned", - source: "rustup component add rust-analyzer", - digest: "unpinned", + version: experiment.producerCommit, + source: "alias of samchon-rust-analyzer", + digest: `git:${experiment.producerCommit}`, }); await installScip(); break; + } case "cpp": case "c": // `bear` alongside clangd because scip-clang declines without a compilation // database, and a Makefile project has no way to emit one — bear records // the compiler invocations as the build runs. A CMake project needs nothing // extra, since configure writes the database on its own. - apt(["clangd", "bear"]); - record({ - tool: "clangd", - version: "unpinned", - source: "apt clangd", - digest: "unpinned", - }); + apt(["clang", "cmake", "ninja-build", "bear"]); + installClangGraphProducer(); record({ tool: "bear", version: "unpinned", diff --git a/tests/experiment/src/strict-lifecycle.mjs b/tests/experiment/src/strict-lifecycle.mjs index fa2cebdc..a39a23cf 100644 --- a/tests/experiment/src/strict-lifecycle.mjs +++ b/tests/experiment/src/strict-lifecycle.mjs @@ -251,9 +251,10 @@ export const runStrictLifecycle = async (experiment, pinnedRoot) => { // itself, and so would asserting that provenance moved: this step edits a // declared build input, so the build universe cannot help but move. What // is worth proving is the precise claim the catalog makes about upstream — - // that the producer ignored the input completely. The universe moves, the - // facts and the source manifest do not, and the row publishes all three so - // a reader can see which one carried the change. + // it tolerates the invalid input without an observable publication-plane + // change. The universe moves, the facts and source manifest do not, and + // the row publishes all three so a reader can see which one carried the + // change. if ( typeof fixture.failureLimitation !== "string" || fixture.failureLimitation === "" @@ -288,37 +289,24 @@ export const runStrictLifecycle = async (experiment, pinnedRoot) => { `${experiment.language}: the malformed input did not move the build universe, so this step compared a generation to itself`, ); } - // The claim itself. Content and manifest cover the facts and the source - // evidence; capabilities and this provider's warnings are the rest of what - // a reader can observe, and neither digest carries them. A producer that - // quietly gave up a capability, or started explaining itself, has not - // ignored the input. - const spoken = (report) => - (report.warnings ?? []) - .filter((warning) => warning.startsWith(`${experiment.strictProvider}:`)) - .sort() - .join(SEPARATOR); - if ( - provenance.content !== prior.content || - provenance.manifest !== prior.manifest || - [...provenance.capabilities].sort().join(",") !== - [...prior.capabilities].sort().join(",") || - spoken(tolerated) !== spoken(priorDump) - ) { + // Compare the observable publication planes directly. The aggregate + // content digest is not independent evidence here: legacy coverage rows + // use the build-universe digest as their target, so content necessarily + // moves whenever the build input above moves even if every semantic fact + // remains byte-identical. + const changed = publicationChanges( + prior, + provenance, + priorDump, + tolerated, + experiment.strictProvider, + ); + if (changed.length !== 0) { throw new Error( - `${experiment.language}: the catalog records this input as ignored, but the published facts, source manifest, capabilities, or provider warnings changed with it`, + `${experiment.language}: the catalog records this input as a tolerated unchanged publication, but these publication planes moved: ${changed.join(", ")}`, ); } - // The other half of the catalog's claim, as a delta rather than an - // absolute: diagnostics this corpus already had are not evidence about - // this input, and the dump carries every lane's diagnostics, not only - // this provider's slice. const diagnosticCount = tolerated.diagnostics?.length ?? 0; - if (diagnosticCount !== previousDiagnostics) { - throw new Error( - `${experiment.language}: the catalog records this producer as reporting nothing about a malformed build input, but diagnostics moved from ${String(previousDiagnostics)} to ${String(diagnosticCount)}`, - ); - } dump = tolerated; previousIdentity = [ provenance.manifest, @@ -504,12 +492,14 @@ export const runStrictLifecycle = async (experiment, pinnedRoot) => { const reproduced = reproducedManifest && reproducedContent; const limitation = experiment.regenerationLimitation; if (!reproduced && limitation === undefined) { + const difference = firstGenerationDifference(cold, retried); throw new Error( `${experiment.language}: restoring the original sources did not reproduce the generation ` + `(manifest ${reproducedManifest ? "unchanged" : "moved"}, ` + `facts ${reproducedContent ? "unchanged" : "moved"}; ` + `cold ${String(cold.nodes.length)} nodes/${String(cold.edges.length)} edges, ` + - `retry ${String(retried.nodes.length)} nodes/${String(retried.edges.length)} edges)`, + `retry ${String(retried.nodes.length)} nodes/${String(retried.edges.length)} edges; ` + + `first difference: ${difference})`, ); } rows.push({ @@ -546,6 +536,53 @@ function strictProvenance(dump, experiment) { return provenance; } +function firstGenerationDifference(left, right) { + for (const plane of [ + "languages", + "nodes", + "edges", + "diagnostics", + "coverage", + "unresolved", + ]) { + const before = left[plane] ?? []; + const after = right[plane] ?? []; + if (before.length !== after.length) { + return `${plane}.length ${String(before.length)} -> ${String(after.length)}`; + } + for (let index = 0; index < before.length; index++) { + const prior = canonicalGenerationValue(before[index]); + const next = canonicalGenerationValue(after[index]); + if (prior !== next) { + return `${plane}[${String(index)}] ${boundedDifference(prior)} -> ${boundedDifference(next)}`; + } + } + } + return "normalized dump fact planes are equal; the strict slice moved before merge"; +} + +function canonicalGenerationValue(value) { + if (value === undefined) return "undefined"; + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(canonicalGenerationValue).join(",")}]`; + } + return `{${Object.entries(value) + .filter(([, nested]) => nested !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map( + ([key, nested]) => + `${JSON.stringify(key)}:${canonicalGenerationValue(nested)}`, + ) + .join(",")}}`; +} + +function boundedDifference(value) { + return value.length <= 320 ? value : `${value.slice(0, 317)}...`; +} + function assertCreatedSymbol( dump, language, @@ -604,7 +641,17 @@ function publicationChanges( ) { const changed = []; if (prior.manifest !== next.manifest) changed.push("manifest"); - if (prior.content !== next.content) changed.push("content"); + for (const plane of [ + "nodes", + "edges", + "coverage", + "unresolved", + "diagnostics", + ]) { + const before = normalizedPublicationPlane(priorDump, plane); + const after = normalizedPublicationPlane(nextDump, plane); + if (before !== after) changed.push(plane); + } if ( [...prior.capabilities].sort().join(",") !== [...next.capabilities].sort().join(",") @@ -617,15 +664,27 @@ function publicationChanges( .sort() .join(SEPARATOR); if (spoken(priorDump) !== spoken(nextDump)) changed.push("warnings"); - if ( - (priorDump.diagnostics?.length ?? 0) !== - (nextDump.diagnostics?.length ?? 0) - ) { - changed.push("diagnostics"); - } return changed; } +function normalizedPublicationPlane(dump, plane) { + const rows = (dump[plane] ?? []).map((row) => { + if ( + row === null || + typeof row !== "object" || + (plane !== "coverage" && plane !== "unresolved") + ) { + return row; + } + // These are generation coordinates, not an independently changed fact. + // The branch already proves the build universe moved. Compare the coverage + // state and unresolved evidence without counting that same movement twice. + const { target: _target, universe: _universe, ...fact } = row; + return fact; + }); + return canonicalGenerationValue(rows); +} + /** A separator no warning can contain, so two lists cannot collide. */ const SEPARATOR = String.fromCharCode(0); diff --git a/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts b/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts index 0cff94f7..b19c9643 100644 --- a/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts +++ b/tests/test-graph/src/features/test_application_exercises_every_request_branch.ts @@ -3,6 +3,20 @@ import { TestValidator } from "@nestia/e2e"; import { ContractGraph } from "../internal/ContractGraph"; import { GraphFixtures } from "../internal/GraphFixtures"; +/** + * Every request member is driven through the real application and its result + * discriminator is compared, in order, against this suite's own request list. + * The property that buys is narrow but not otherwise held anywhere: no arm may + * answer as another arm. Each one selects its own result union member, and a + * mis-wired `switch` that returned an overview for a trace would satisfy the + * type checker, every per-operation suite that only calls its own request, and + * the coverage gate that only asks whether the line ran. + * + * This is not the guard against an unexercised arm. Both the driven list here + * and {@link GraphFixtures.GRAPH_REQUEST_TYPES} are hand-maintained, so a new + * union member reaches neither by itself; the 100 percent branch-coverage gate + * is what refuses an arm nothing runs. + */ export const test_application_exercises_every_request_branch = async () => { const app = ContractGraph.createApplication(); const requests = [ @@ -12,6 +26,7 @@ export const test_application_exercises_every_request_branch = async () => { { type: "details", handles: ["Root.Service.run"], neighbors: true }, { type: "overview", aspect: "all" }, { type: "tour", reinterpretations: ["Root.Service.run"] }, + { type: "topology" }, { type: "escape", reason: "outside graph", nextStep: "answer without graph" }, ] as const; diff --git a/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts b/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts index 2c85640c..2fca5bd4 100644 --- a/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts +++ b/tests/test-graph/src/features/test_contract_fixture_covers_every_graph_node_and_edge_kind.ts @@ -1,8 +1,22 @@ import { TestValidator } from "@nestia/e2e"; -import { SamchonGraphMemory } from "@samchon/graph"; +import { GRAPH_EDGE_KINDS, SamchonGraphMemory } from "@samchon/graph"; import { GraphFixtures } from "../internal/GraphFixtures"; +/** + * The contract fixture is the shared corpus every operation test reasons from, + * so a kind it happens not to contain is a kind nothing tests — silently, and + * more so after each new family is added. This is the completeness gate for + * that. + * + * How much each half is worth is worth knowing before relying on either. The + * edge half is anchored through one more step: the fixture is compared to a + * suite-local list, and a separate assertion holds that list to the package's + * exported `GRAPH_EDGE_KINDS`, so a new family does reach it by existing — + * except through the traversal-only exemption, which is a second hand-kept + * list. The node half has no such anchor at all: it compares the fixture to a + * hand-kept list and nothing ties that list to the public union. + */ export const test_contract_fixture_covers_every_graph_node_and_edge_kind = () => { const { dump } = GraphFixtures.createContractFixture(); const graph = SamchonGraphMemory.from(dump); @@ -12,6 +26,11 @@ export const test_contract_fixture_covers_every_graph_node_and_edge_kind = () => [...new Set(graph.nodes.map((node) => node.kind))].sort(), [...GraphFixtures.GRAPH_NODE_KINDS].sort(), ); + TestValidator.equals( + "the protocol coverage order contains the exact public edge-kind union", + GRAPH_EDGE_KINDS, + GraphFixtures.GRAPH_EDGE_KINDS, + ); // Every edge kind an index can store is in the fixture. `dispatches` is the // one it cannot: a forward walk synthesizes it when a call lands on a // declaration with no body, so it lives in a traversal and never in a graph. diff --git a/tests/test-graph/src/features/test_cpp_clang_snapshot_adapter_and_client_are_atomic.ts b/tests/test-graph/src/features/test_cpp_clang_snapshot_adapter_and_client_are_atomic.ts new file mode 100644 index 00000000..4708bf2c --- /dev/null +++ b/tests/test-graph/src/features/test_cpp_clang_snapshot_adapter_and_client_are_atomic.ts @@ -0,0 +1,1128 @@ +import { TestValidator } from "@nestia/e2e"; +import { + CPP_CLANG_PROVIDER, + CPP_CLANG_PRODUCER_COMMIT, + CppGraphClient, + CppGraphSnapshotAdapter, + GRAPH_EDGE_KINDS, + cppGraphProvider, + type ICppGraphSnapshot, +} from "@samchon/graph"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { GraphPaths } from "../internal/GraphPaths.js"; + +const COMMIT = CPP_CLANG_PRODUCER_COMMIT; + +/** + * Proves the pinned clangd snapshot's native trust boundary, C/C++ slice + * projection, source identities, resident lifecycle, pagination, and fallback + * registration as one atomic compiler-owned provider contract. + */ +export const test_cpp_clang_snapshot_adapter_and_client_are_atomic = async () => { + const root = fixtureRoot(); + const raw = nativeSnapshot(root); + const adapter = new CppGraphSnapshotAdapter(root, COMMIT); + const initial = adapter.apply(raw, () => undefined); + TestValidator.equals( + "the Clang adapter preserves both compilation configurations and semantic facts", + [ + initial.mode, + initial.snapshot.languages, + new Set(initial.snapshot.protocol?.targets).size, + [...new Set(initial.snapshot.edges.map((edge) => edge.kind))].sort(), + initial.snapshot.coverage?.length, + initial.snapshot.unresolved?.every( + (site) => site.reason === "provider-gap" || site.reason === "dynamic", + ), + initial.snapshot.provenance.provider, + initial.snapshot.sources.size, + [...initial.snapshot.sources.keys()].every( + (file) => file.startsWith("bundled:///") || path.isAbsolute(file), + ), + ], + [ + "initial", + ["c", "cpp"], + 2, + [ + "accesses", + "calls", + "contains", + "exports", + "extends", + "imports", + "instantiates", + "overrides", + "references", + "type_ref", + ], + GRAPH_EDGE_KINDS.length * 4, + true, + CPP_CLANG_PROVIDER, + 2, + true, + ], + ); + const nodes = new Map(initial.snapshot.nodes.map((node) => [node.id, node])); + TestValidator.predicate( + "Clang RelationBaseOf becomes derived-to-base inheritance", + initial.snapshot.edges.some( + (edge) => + edge.kind === "extends" && + nodes.get(edge.from)?.name === "Derived" && + nodes.get(edge.to)?.name === "Base", + ), + ); + const overlaid = new CppGraphSnapshotAdapter(root, COMMIT).apply( + nativeSnapshot(root, ["--checker-overlay"]), + () => undefined, + ); + const overlaidSource = overlaid.snapshot.sources.get( + path.resolve(root, "main.cpp"), + ); + TestValidator.predicate( + "checker overlays preserve a distinct native disk digest", + overlaidSource !== undefined && + overlaidSource.checkerDigest !== overlaidSource.diskDigest && + overlaidSource.diskDigest === + sha256(fs.readFileSync(path.resolve(root, "main.cpp"), "utf8")), + ); + const edgeCases = new CppGraphSnapshotAdapter(root, COMMIT).apply( + nativeSnapshot(root, ["--edge-cases"]), + () => undefined, + ); + TestValidator.predicate( + "the adapter preserves valid empty locations, URI forms, and unknown native kinds", + edgeCases.snapshot.nodes.some( + (node) => + node.name === "caller" && node.qualifiedName === "fixture::caller", + ) && + edgeCases.snapshot.nodes.some( + (node) => node.name === "Derived" && node.kind === "external_symbol", + ) && + edgeCases.snapshot.nodes.some( + (node) => node.name === "c:@F@external#" && node.external, + ) && + edgeCases.snapshot.diagnostics.some( + (diagnostic) => diagnostic.line === 0 && diagnostic.column === 0, + ) && + edgeCases.snapshot.edges.some( + (edge) => edge.kind === "overrides" && edge.evidence === undefined, + ) && + edgeCases.snapshot.sources.has("bundled:///fixture/system.h") && + edgeCases.snapshot.sources.has(path.resolve(root, "relative.cpp")), + ); + TestValidator.error( + "a source URI that cannot canonicalize fails the common protocol closed", + () => + new CppGraphSnapshotAdapter(root, COMMIT).apply( + nativeSnapshot(root, ["--invalid-source-uri"]), + () => undefined, + ), + ); + TestValidator.error( + "an unsupported source URI cannot impersonate a project-local file", + () => + new CppGraphSnapshotAdapter(root, COMMIT).apply( + nativeSnapshot(root, ["--unsupported-source-uri"]), + () => undefined, + ), + ); + assertCrossVolumeIdentity(root, raw); + assertUnicodeManifestOrdering(); + assertNativeRefusals(root, raw); + await assertProvider(root); + await assertClientLifecycle(root); + await assertClientInputShapes(); + await assertClientPagination(); + await assertClientFailures(fixtureRoot()); +}; + +function fixtureRoot(): string { + const root = GraphPaths.createTempDirectory("samchon-graph-cpp-native-"); + fs.mkdirSync(path.join(root, "include")); + fs.writeFileSync(path.join(root, "main.cpp"), "void caller() {}\n"); + fs.writeFileSync(path.join(root, "absolute.cpp"), "void absolute() {}\n"); + fs.writeFileSync(path.join(root, "include", "fixture.h"), "void callee();\n"); + fs.writeFileSync( + path.join(root, "compile_commands.json"), + JSON.stringify([ + { + directory: root, + file: "main.cpp", + arguments: ["clang", "-x", "c", "-c", "main.cpp"], + }, + { + directory: root, + file: "main.cpp", + arguments: ["clang++", "-x", "c++", "-c", "main.cpp"], + }, + ]), + ); + return root; +} + +async function assertProvider(root: string): Promise { + const override = "SAMCHON_GRAPH_CLANGD_SNAPSHOT"; + const command = nodeShim(root, "samchon-clangd", COMMIT); + const wrong = nodeShim(root, "wrong-clangd", "f".repeat(40)); + const prefixCollision = nodeShim( + root, + "prefix-collision-clangd", + `${COMMIT.slice(0, 9)}${"f".repeat(31)}`, + ); + const resolved = cppGraphProvider.resolve(root, { + ...process.env, + [override]: command, + }); + TestValidator.equals( + "the C/C++ provider resolves only its pinned compiler producer", + [ + resolved !== undefined, + cppGraphProvider.resolve(root, { + ...process.env, + [override]: wrong, + }), + cppGraphProvider.resolve(root, { + ...process.env, + [override]: prefixCollision, + }), + cppGraphProvider.fallbacks?.map((provider) => provider.name), + cppGraphProvider.configuration?.(root, { [override]: command }), + cppGraphProvider.configuration?.(root, {}), + ], + [ + true, + undefined, + undefined, + ["scip-clang"], + [`producer-commit=${COMMIT}`, `${override}=${command}`], + [`producer-commit=${COMMIT}`, `${override}=unconfigured`], + ], + ); + TestValidator.predicate( + "whole-database Clang generations refuse bounded and caller-owned modes", + cppGraphProvider.refuse({ maxFiles: 1 })?.includes("maxFiles") === true && + cppGraphProvider.refuse({ server: "clangd" })?.includes("server") === true && + cppGraphProvider + .refuse({ lspReferenceLimit: 1 }) + ?.includes("lspReferenceLimit") === true && + cppGraphProvider.refuse({}) === undefined, + ); + const session = cppGraphProvider.open({ + root, + command: resolved!, + languages: ["c", "cpp"], + options: {}, + }); + try { + TestValidator.equals( + "the registered C/C++ provider opens its pinned producer contract", + (await session.refresh()).snapshot.provenance.authority, + "compiler", + ); + } finally { + await session.close(); + } + const cppOnly = cppGraphProvider.open({ + root, + command: resolved!, + languages: ["cpp"], + options: {}, + }); + try { + const selected = await cppOnly.refresh(); + TestValidator.predicate( + "one requested language projects its slice from the producer's atomic mixed C/C++ universe", + selected.snapshot.languages.length === 1 && + selected.snapshot.languages[0] === "cpp" && + selected.snapshot.nodes.every((node) => node.language === "cpp"), + ); + } finally { + await cppOnly.close(); + } + const absent = GraphPaths.createTempDirectory("samchon-graph-cpp-no-cdb-"); + fs.writeFileSync(path.join(absent, "compile_commands.json"), "[]"); + fs.mkdirSync(path.join(absent, "build")); + fs.writeFileSync(path.join(absent, "build", "compile_commands.json"), "bad"); + TestValidator.error( + "the C/C++ provider refuses a project without a compilation database", + () => cppGraphProvider.prepare?.(absent, {}), + ); + fs.writeFileSync(path.join(absent, "compile_commands.json"), "bad"); + fs.writeFileSync( + path.join(absent, "build", "compile_commands.json"), + JSON.stringify([{ directory: absent, file: "main.cpp" }]), + ); + cppGraphProvider.prepare?.(absent, {}); +} + +function nativeSnapshot( + root: string, + args: readonly string[] = [], +): ICppGraphSnapshot { + const result = spawnSync( + process.execPath, + [ + GraphPaths.fakeCppGraphServer, + "--snapshot", + `--commit=${COMMIT}`, + ...args, + ], + { cwd: root, encoding: "utf8", shell: false }, + ); + if (result.status !== 0 || result.error !== undefined) { + throw result.error ?? new Error(result.stderr); + } + return JSON.parse(result.stdout) as ICppGraphSnapshot; +} + +function assertCrossVolumeIdentity( + root: string, + valid: ICppGraphSnapshot, +): void { + if (process.platform !== "win32") return; + const rootDrive = path.parse(root).root.slice(0, 1).toUpperCase(); + const foreignDrive = rootDrive === "C" ? "D" : "C"; + const foreign = path.join(`${foreignDrive}:\\`, "sdk", "foreign.hpp"); + const foreignUri = pathToFileURL(foreign).href; + const candidate = structuredClone(valid); + const graph = candidate.upserts[0]!.graph; + graph.sources.push({ + uri: foreignUri, + digest: sha256("foreign checker source"), + diskDigest: sha256("foreign disk source"), + flags: 0, + }); + graph.symbols[0]!.definition.file = foreignUri; + resealSnapshot(candidate); + const snapshot = new CppGraphSnapshotAdapter(root, COMMIT).apply( + candidate, + () => undefined, + ).snapshot; + TestValidator.predicate( + "a Windows source on another volume keeps a real manifest path and an opaque external graph identity", + snapshot.sources.has(path.normalize(foreign)) && + snapshot.nodes.some( + (node) => + node.name === "caller" && + node.external && + node.file.startsWith("bundled:///clang/filesystem/"), + ), + ); +} + +function assertUnicodeManifestOrdering(): void { + const root = GraphPaths.createTempDirectory("samchon-graph-cpp-unicode-"); + const supplementary = "\u{10000}.cpp"; + const privateUse = "\uE000.cpp"; + const commands = [supplementary, privateUse].map((file) => { + fs.writeFileSync(path.join(root, file), "void caller() {}\n"); + return { + directory: root, + file, + arguments: ["clang++", "-x", "c++", "-c", file], + }; + }); + fs.writeFileSync( + path.join(root, "compile_commands.json"), + JSON.stringify(commands), + ); + const raw = nativeSnapshot(root); + const snapshot = new CppGraphSnapshotAdapter(root, COMMIT).apply( + raw, + () => undefined, + ).snapshot; + TestValidator.equals( + "native manifest order compares raw UTF-8 bytes instead of UTF-16 code units", + [ + raw.upserts.map((shard) => path.basename(shard.source)), + snapshot.sources.size, + ], + [[privateUse, supplementary], 2], + ); +} + +function assertNativeRefusals( + root: string, + valid: ICppGraphSnapshot, +): void { + const rejects = ( + label: string, + mutate: (value: ICppGraphSnapshot) => void, + ): void => { + TestValidator.error(label, () => { + const candidate = structuredClone(valid); + mutate(candidate); + new CppGraphSnapshotAdapter(root, COMMIT).apply(candidate, () => undefined); + }); + }; + rejects("a foreign Clang producer is refused", (value) => { + value.producer.commit = "wrong"; + }); + rejects("an unsupported native protocol is refused", (value) => { + value.protocolVersion = 2; + }); + rejects("a malformed native envelope is refused", (value) => { + value.sequence = 0; + }); + rejects("a malformed native base is refused", (value) => { + value.baseGeneration = "bad"; + }); + rejects("a malformed Clang universe is refused", (value) => { + value.universe.targets.reverse(); + value.universe.targets.push("duplicate"); + }); + rejects("a malformed native phase is refused", (value) => { + value.phases.totalMillis = -1; + }); + rejects("a malformed assembled native page is refused", (value) => { + value.page.offset = 1; + }); + rejects("a malformed native delete set is refused", (value) => { + value.deletes = ["z", "a"]; + }); + rejects("a non-canonical native manifest is refused", (value) => { + value.manifest.push({ ...value.manifest[0]! }); + }); + rejects("a malformed native source is refused", (value) => { + value.upserts[0]!.graph.sources[0]!.digest = "wrong"; + }); + rejects("a malformed native disk digest is refused", (value) => { + value.upserts[0]!.graph.sources[0]!.diskDigest = "wrong"; + }); + rejects("a malformed native shard is refused", (value) => { + value.upserts[0]!.source = ""; + }); + rejects("a foreign compiler fingerprint is refused", (value) => { + value.upserts[0]!.graph.producerFingerprint = "0".repeat(64); + }); + rejects("a malformed native symbol is refused", (value) => { + value.upserts[0]!.graph.symbols[0]!.name = ""; + }); + rejects("an unsupported URI cannot hide in an unselected native range", (value) => { + value.upserts[0]!.graph.symbols[0]!.declaration.file = + "repo:///hidden-declaration.cpp"; + resealSnapshot(value); + }); + rejects("a malformed native occurrence is refused", (value) => { + value.upserts[0]!.graph.occurrences[0]!.usr = ""; + }); + rejects("a malformed native relation is refused", (value) => { + value.upserts[0]!.graph.relations[0]!.subjectId = ""; + }); + rejects("a malformed native macro is refused", (value) => { + value.upserts[0]!.graph.macros[0]!.name = ""; + }); + rejects("a malformed native include is refused", (value) => { + value.upserts[0]!.graph.includes[0]!.source = ""; + }); + rejects("a malformed native module is refused", (value) => { + value.upserts[0]!.graph.modules[0]!.name = ""; + }); + rejects("a malformed native diagnostic is refused", (value) => { + value.upserts[0]!.graph.diagnostics[0]!.message = ""; + }); + rejects("a reversed native range is refused", (value) => { + const range = value.upserts[0]!.graph.occurrences[0]!.spelling; + range.startColumn = range.endColumn + 1; + }); + rejects("a negative native range is refused", (value) => { + value.upserts[0]!.graph.occurrences[0]!.spelling.startLine = -1; + }); + rejects("a non-array native universe coordinate is refused", (value) => { + (value.universe as unknown as { targets: null }).targets = null; + }); + rejects("a non-string native universe coordinate is refused", (value) => { + (value.universe.targets as unknown[])[0] = 1; + }); + rejects("an empty required native universe coordinate is refused", (value) => { + value.universe.targets[0] = ""; + }); + rejects("an incomplete native coverage matrix is refused", (value) => { + value.upserts[0]!.coverage.pop(); + }); + rejects("an invalid native coverage row is refused", (value) => { + value.upserts[0]!.coverage[0]!.state = "wrong" as "complete"; + }); + rejects("a mismatched native main source is refused", (value) => { + value.upserts[0]!.source += ".other"; + }); + rejects("a mismatched native shard digest is refused", (value) => { + value.upserts[0]!.digest = "0".repeat(64); + }); + rejects("a mismatched native generation is refused", (value) => { + value.generation = "0".repeat(64); + }); + rejects("a shard-extraneous native universe is refused", (value) => { + value.universe.targets = ["other-target"]; + }); + rejects("a malformed native graph is refused", (value) => { + value.upserts[0]!.graph.targetTriple = ""; + }); + const stale = new CppGraphSnapshotAdapter(root, COMMIT); + stale.apply(structuredClone(valid), () => undefined); + const malformedNoop = structuredClone(valid); + malformedNoop.baseGeneration = valid.generation; + malformedNoop.upserts = []; + malformedNoop.deletes = []; + malformedNoop.manifest = []; + malformedNoop.page = { offset: 0, count: 0, total: 0, nextCursor: null }; + malformedNoop.phases.cacheHit = true; + malformedNoop.universe.targets = ["foreign-target"]; + TestValidator.error( + "an unchanged frame still proves that its universe describes the resident shards", + () => stale.apply(malformedNoop, () => undefined), + ); + const delta = structuredClone(valid); + delta.baseGeneration = "0".repeat(64); + TestValidator.error("a Clang delta must name the exact resident base", () => + stale.apply(delta, () => undefined), + ); + const invalidDelete = structuredClone(valid); + invalidDelete.baseGeneration = valid.generation; + invalidDelete.deletes = ["missing-shard"]; + TestValidator.error("a Clang delta cannot delete an absent shard", () => + stale.apply(invalidDelete, () => undefined), + ); + const duplicateDelta = structuredClone(valid); + duplicateDelta.baseGeneration = valid.generation; + duplicateDelta.deletes = [duplicateDelta.upserts[0]!.key]; + TestValidator.error("a Clang delta cannot delete and replace one shard", () => + stale.apply(duplicateDelta, () => undefined), + ); + const manifestMismatch = structuredClone(valid); + manifestMismatch.manifest[0]!.digest = "0".repeat(64); + TestValidator.error("native shards must exactly match their manifest", () => + new CppGraphSnapshotAdapter(root, COMMIT).apply( + manifestMismatch, + () => undefined, + ), + ); + + const partial = structuredClone(valid); + partial.baseGeneration = valid.generation; + partial.sequence += 1; + const changed = partial.upserts[0]!; + changed.graph.diagnostics[0]!.message += " (changed)"; + changed.digest = nativeShardDigest(changed); + partial.upserts = [changed]; + partial.manifest = partial.manifest.map((entry) => + entry.key === changed.key ? { key: entry.key, digest: changed.digest } : entry, + ); + partial.generation = nativeGeneration( + partial.universe.digest, + partial.manifest, + ); + partial.page = { offset: 0, count: 1, total: 1, nextCursor: null }; + const partialAdapter = new CppGraphSnapshotAdapter(root, COMMIT); + partialAdapter.apply(structuredClone(valid), () => undefined); + TestValidator.equals( + "a same-universe native delta retains unchanged graph shards", + partialAdapter.apply(partial, () => undefined).mode, + "incremental", + ); + + const database = path.join(root, "compile_commands.json"); + const commands = JSON.parse(fs.readFileSync(database, "utf8")) as unknown[]; + fs.writeFileSync(database, JSON.stringify(commands.slice(1))); + const reloaded = stale.apply(nativeSnapshot(root), () => undefined); + TestValidator.equals( + "a full native generation with a changed language universe reloads atomically", + [reloaded.mode, reloaded.snapshot.languages], + ["reload", ["cpp"]], + ); + fs.writeFileSync(database, JSON.stringify(commands)); + + const cppCommands = commands.map((row, index) => ({ + ...(row as Record), + arguments: [ + "clang++", + "-x", + "c++", + `-DGRAPH_CONFIGURATION=${String(index)}`, + "-c", + "main.cpp", + ], + })); + fs.writeFileSync(database, JSON.stringify(cppCommands)); + const sameLanguage = new CppGraphSnapshotAdapter(root, COMMIT); + const both = nativeSnapshot(root); + sameLanguage.apply(both, () => undefined); + fs.writeFileSync(database, JSON.stringify(cppCommands.slice(1))); + const one = nativeSnapshot(root); + const deletionDelta = structuredClone(one); + deletionDelta.baseGeneration = both.generation; + deletionDelta.deletes = both.manifest + .filter( + (entry) => !one.manifest.some((candidate) => candidate.key === entry.key), + ) + .map((entry) => entry.key) + .sort(); + deletionDelta.upserts = []; + deletionDelta.page = { offset: 0, count: 0, total: 0, nextCursor: null }; + const universeReload = sameLanguage.apply( + deletionDelta, + () => undefined, + ); + TestValidator.equals( + "a configuration-universe deletion reloads every surviving shard", + [universeReload.mode, universeReload.snapshot.languages], + ["reload", ["cpp"]], + ); + fs.writeFileSync(database, JSON.stringify(commands)); +} + +async function assertClientLifecycle(root: string): Promise { + const requestLog = path.join(root, "requests.ndjson"); + const watchLog = path.join(root, "watches.ndjson"); + const client = cppClient(root, [ + `--request-log=${requestLog}`, + `--watch-log=${watchLog}`, + ]); + const initial = await client.refresh(); + const unchanged = await client.refresh(); + fs.writeFileSync(path.join(root, "main.cpp"), "void edited() {}\n"); + const edited = await client.refresh(); + const database = path.join(root, "compile_commands.json"); + const commands = JSON.parse(fs.readFileSync(database, "utf8")) as unknown[]; + fs.writeFileSync(database, JSON.stringify(commands.slice(1))); + const deleted = await client.refresh(); + TestValidator.equals( + "the resident Clang client reuses no-ops and commits one edited delta", + [ + [initial.changed, initial.mode, initial.generation], + [unchanged.changed, unchanged.mode, unchanged.generation], + [edited.changed, edited.mode, edited.generation], + [deleted.changed, deleted.mode, deleted.generation], + client.generation, + edited.snapshot.nodes.some((node) => node.name === "editedCaller"), + readLines(requestLog).map((row) => row.knownGeneration !== undefined), + readLines(watchLog).map((row) => row.changes[0]?.type), + ], + [ + [true, "initial", 1], + [false, "unchanged", 1], + [true, "incremental", 2], + [true, "reload", 3], + 3, + true, + [false, true, true, true], + [1, 2, 2], + ], + ); + await client.close(); + await client.close(); + await rejected( + "a closed Clang graph session rejects refresh", + client.refresh(), + "session is closed", + ); +} + +async function assertClientPagination(): Promise { + const root = GraphPaths.createTempDirectory("samchon-graph-cpp-pages-"); + const commands: Array> = []; + for (let index = 0; index < 35; ++index) { + const file = `page-${String(index).padStart(2, "0")}.cpp`; + fs.writeFileSync(path.join(root, file), "void caller() {}\n"); + commands.push({ + directory: root, + file, + arguments: ["clang++", "-x", "c++", "-c", file], + }); + } + fs.writeFileSync( + path.join(root, "compile_commands.json"), + JSON.stringify(commands), + ); + const requestLog = path.join(root, "requests.ndjson"); + const client = cppClient(root, [`--request-log=${requestLog}`]); + try { + const refreshed = await client.refresh(); + const requests = readLines(requestLog); + TestValidator.equals( + "the resident client assembles bounded native pages before one atomic commit", + [ + refreshed.snapshot.sources.size, + refreshed.snapshot.protocol?.shards.length, + requests.length, + requests.map((request) => request.maxShards), + requests.map((request) => typeof request.cursor), + ], + [35, 35, 2, [32, 32], ["undefined", "string"]], + ); + for (const [corruption, message] of [ + ["generation", "malformed paged generation"], + ["envelope", "malformed snapshot page envelope"], + ["telemetry", "malformed page telemetry"], + ["cache", "malformed page cache state"], + ["early", "paged generation ended early"], + ["cursor", "invalid continuation cursor"], + ["metadata", "continuation repeated generation metadata"], + ["cross-generation", "continuation crossed generations"], + ] as const) { + const broken = cppClient(root, [`--page-corruption=${corruption}`]); + await rejected( + `the client rejects ${corruption} pagination corruption`, + broken.refresh(), + message, + ); + await broken.close(); + } + } finally { + await client.close(); + } + const defaultArgs = new CppGraphClient({ + root, + languages: ["cpp"], + command: process.execPath, + producerCommit: COMMIT, + requestTimeoutMs: 10, + }); + await defaultArgs.close(); +} + +async function assertClientInputShapes(): Promise { + const root = GraphPaths.createTempDirectory("samchon-graph-cpp-inputs-"); + const build = path.join(root, "build"); + fs.mkdirSync(build); + fs.writeFileSync(path.join(root, "compile_commands.json"), "{}"); + for (const file of ["fallback.cpp", "direct.cpp", "absolute.cpp"]) { + fs.writeFileSync(path.join(root, file), "void caller() {}\n"); + } + fs.writeFileSync(path.join(build, "fallback.cpp"), "void caller() {}\n"); + const absolute = path.join(root, "absolute.cpp"); + const buildCommands = [ + {}, + { file: "" }, + { file: "fallback.cpp" }, + { directory: root, file: "direct.cpp" }, + { directory: "", file: absolute }, + ]; + fs.writeFileSync( + path.join(build, "compile_commands.json"), + JSON.stringify(buildCommands), + ); + const watchLog = path.join(root, "input-watches.ndjson"); + const client = new CppGraphClient({ + root, + languages: ["c", "cpp"], + command: process.execPath, + args: [ + GraphPaths.fakeCppGraphServer, + `--commit=${COMMIT}`, + `--watch-log=${watchLog}`, + ], + producerCommit: COMMIT, + requestTimeoutMs: 5_000, + readyTimeoutMs: 10_000, + }); + try { + await client.refresh(); + fs.writeFileSync(path.join(root, ".clangd"), "Diagnostics: {}\n"); + await client.refresh(); + fs.unlinkSync(path.join(root, ".clangd")); + await client.refresh(); + fs.writeFileSync( + path.join(build, "compile_commands.json"), + JSON.stringify( + buildCommands.filter( + (row) => !("file" in row) || row.file !== "direct.cpp", + ), + ), + ); + await client.refresh(); + await client.refresh(); + fs.unlinkSync(absolute); + fs.writeFileSync( + path.join(build, "compile_commands.json"), + JSON.stringify( + buildCommands.filter( + (row) => + !("file" in row) || + (row.file !== "direct.cpp" && row.file !== absolute), + ), + ), + ); + await client.refresh(); + await client.refresh(); + const watched = readLines(watchLog).flatMap((row) => row.changes); + TestValidator.equals( + "CDB input discovery accepts fallback directories and tracks null-to-file transitions", + watched + .filter((change) => String(change.uri).endsWith("/.clangd")) + .map((change) => change.type), + [1, 3], + ); + TestValidator.equals( + "a file removed only from the compilation database is not falsely deleted on the following refresh", + watched + .filter((change) => String(change.uri).endsWith("/direct.cpp")) + .map((change) => change.type), + [1], + ); + TestValidator.equals( + "a deleted source is notified once and never reborn from a stale null baseline", + watched + .filter((change) => String(change.uri).endsWith("/absolute.cpp")) + .map((change) => change.type), + [1, 3], + ); + } finally { + await client.close(); + } +} + +async function assertClientFailures(root: string): Promise { + const retry = cppClient(root, ["--retry=1", "--content-modified=1"]); + TestValidator.equals( + "retryable Clang readiness and movement errors are polled to success", + (await retry.refresh()).changed, + true, + ); + await retry.close(); + + const movementRoot = fixtureRoot(); + const movementWatchLog = path.join(movementRoot, "movement-watches.ndjson"); + const movement = cppClient(movementRoot, [ + "--content-modified=1", + "--move-input-on-content-modified", + `--watch-log=${movementWatchLog}`, + ]); + await movement.refresh(); + await movement.close(); + TestValidator.equals( + "input movement discovered during a snapshot retry is notified", + readLines(movementWatchLog).map((row) => row.changes[0]?.type), + [1, 2], + ); + + const postSnapshotRoot = fixtureRoot(); + const postSnapshotWatchLog = path.join( + postSnapshotRoot, + "post-snapshot-watches.ndjson", + ); + let moveAfterSnapshot = true; + const postSnapshot = cppClient( + postSnapshotRoot, + [`--watch-log=${postSnapshotWatchLog}`], + { + validate: () => { + if (!moveAfterSnapshot) return; + moveAfterSnapshot = false; + fs.writeFileSync( + path.join(postSnapshotRoot, "main.cpp"), + "void movedAfterSnapshot() {}\n", + ); + }, + }, + ); + await postSnapshot.refresh(); + await postSnapshot.refresh(); + await postSnapshot.close(); + TestValidator.equals( + "input movement after a frozen snapshot remains visible to the next refresh", + readLines(postSnapshotWatchLog).map((row) => row.changes[0]?.type), + [1, 2], + ); + + const unknownDiskRoot = fixtureRoot(); + const unknownDiskWatchLog = path.join( + unknownDiskRoot, + "unknown-disk-watches.ndjson", + ); + const unknownDisk = cppClient(unknownDiskRoot, [ + "--edge-cases", + "--empty-disk-digest", + `--watch-log=${unknownDiskWatchLog}`, + ]); + await unknownDisk.refresh(); + await unknownDisk.refresh(); + await unknownDisk.close(); + TestValidator.equals( + "a source without a producer disk identity is conservatively re-notified", + readLines(unknownDiskWatchLog) + .flatMap((row) => row.changes) + .filter((change) => String(change.uri).endsWith("/main.cpp")) + .map((change) => change.type), + [1, 1], + ); + + const timedOut = cppClient(root, ["--content-modified=1"], { + readyTimeoutMs: 0, + }); + await rejected( + "retryable movement still obeys the readiness deadline", + timedOut.refresh(), + "did not become ready", + ); + await timedOut.close(); + + const configured = cppClient( + root, + [ + "--request-configuration", + "--request-empty-configuration", + "--request-unknown", + ], + { initializationOptions: { graph: true } }, + ); + await configured.refresh({ signal: new AbortController().signal }); + await configured.close(); + + const initializeError = cppClient(root, ["--initialize-error"]); + await rejected( + "initialization failures reject the resident session", + initializeError.refresh({ signal: new AbortController().signal }), + "fixture initialize failure", + ); + await initializeError.close(); + + const initializing = cppClient(root, ["--hang-initialize"]); + const initializationAbort = new AbortController(); + const initialization = initializing.refresh({ signal: initializationAbort.signal }); + initializationAbort.abort("initialize cancellation"); + await rejected( + "an initializing Clang session remains cancellable", + initialization, + "cancel", + ); + await initializing.close(); + + const malformed = cppClient(root, ["--malformed"]); + await rejected( + "a malformed Clang response fails closed", + malformed.refresh(), + "identity/commit mismatch", + ); + await malformed.close(); + + const internal = cppClient(root, ["--internal-error"]); + await rejected( + "a non-retryable Clang producer error is surfaced", + internal.refresh(), + "fixture internal failure", + ); + await internal.close(); + + const hanging = cppClient(root, ["--hang"]); + const abort = new AbortController(); + const refresh = hanging.refresh({ signal: abort.signal }); + setTimeout(() => abort.abort("fixture cancellation"), 20).unref?.(); + await rejected( + "an active Clang snapshot request remains cancellable", + refresh, + "abort|cancel", + ); + await hanging.close(); + + const delaying = cppClient(root, ["--retry=100"]); + await ( + delaying as unknown as { + initialize(signal: AbortSignal): Promise; + } + ).initialize(new AbortController().signal); + const delayAbort = new AbortController(); + const delayed = delaying.refresh({ signal: delayAbort.signal }); + setTimeout(() => delayAbort.abort("delay cancellation"), 20).unref?.(); + await rejected( + "retry delay remains cancellable", + delayed, + "cancel", + ); + await delaying.close(); + + const queued = cppClient(root, ["--hang"]); + const activeAbort = new AbortController(); + const active = queued.refresh({ signal: activeAbort.signal }); + const queuedAbort = new AbortController(); + const waiting = queued.refresh({ signal: queuedAbort.signal }); + queuedAbort.abort("queued cancellation"); + await rejected("a queued Clang refresh is cancellable", waiting, "cancel"); + activeAbort.abort("active cancellation"); + await rejected("the active refresh is also cancelled", active, "cancel"); + await queued.close(); + + const alreadyAborted = cppClient(root, []); + const aborted = new AbortController(); + aborted.abort("preflight cancellation"); + await rejected( + "an already-cancelled refresh never enters the queue", + alreadyAborted.refresh({ signal: aborted.signal }), + "cancel", + ); + await alreadyAborted.close(); + + const preinitializing = cppClient(root, []); + await ( + preinitializing as unknown as { + initialize(signal: AbortSignal): Promise; + } + ).initialize(new AbortController().signal); + const preinitializedAbort = new AbortController(); + preinitializedAbort.abort("preinitialized cancellation"); + await rejected( + "the initialization race rejects an already-aborted caller signal", + ( + preinitializing as unknown as { + initialize(signal: AbortSignal): Promise; + } + ).initialize(preinitializedAbort.signal), + "cancel", + ); + await preinitializing.close(); + + const directCancellation = cppClient(root, []); + await rejected( + "the snapshot loop checks cancellation before requesting a page", + ( + directCancellation as unknown as { + requestSnapshot(signal: AbortSignal): Promise; + } + ).requestSnapshot({ aborted: true, reason: undefined } as AbortSignal), + "cancel", + ); + await directCancellation.close(); + + const stringFailure = cppClient(root, [], { + validate: () => { + throw "fixture validation string"; + }, + }); + await rejected( + "non-Error validation failures are normalized", + stringFailure.refresh(), + "fixture validation string", + ); + await stringFailure.close(); + + const absent = GraphPaths.createTempDirectory("samchon-graph-cpp-empty-client-"); + const noDatabase = cppClient(absent, []); + await rejected( + "an empty compilation database fails closed", + noDatabase.refresh(), + "universe|generation", + ); + await noDatabase.close(); +} + +function cppClient( + root: string, + args: readonly string[], + options: { + initializationOptions?: unknown; + readyTimeoutMs?: number; + validate?: () => void; + } = {}, +): CppGraphClient { + return new CppGraphClient({ + root, + languages: ["c", "cpp"], + command: process.execPath, + args: [GraphPaths.fakeCppGraphServer, `--commit=${COMMIT}`, ...args], + producerCommit: COMMIT, + initializationOptions: options.initializationOptions, + requestTimeoutMs: 5_000, + readyTimeoutMs: options.readyTimeoutMs ?? 10_000, + validate: options.validate, + }); +} + +function nodeShim( + root: string, + name: string, + commit: string, +): string { + const directory = path.join(root, "shims"); + fs.mkdirSync(directory, { recursive: true }); + const file = path.join( + directory, + process.platform === "win32" ? `${name}.cmd` : name, + ); + const invocation = [ + `"${process.execPath}"`, + `"${GraphPaths.fakeCppGraphServer}"`, + `--commit=${commit}`, + ].join(" "); + fs.writeFileSync( + file, + process.platform === "win32" + ? `@echo off\r\n${invocation} %*\r\n` + : `#!/bin/sh\nexec ${invocation} "$@"\n`, + ); + if (process.platform !== "win32") fs.chmodSync(file, 0o755); + return file; +} + +function readLines(file: string): Array> { + return fs + .readFileSync(file, "utf8") + .trim() + .split(/\r?\n/u) + .filter((line) => line !== "") + .map((line) => JSON.parse(line) as Record); +} + +function nativeShardDigest(shard: ICppGraphSnapshot.IShard): string { + return sha256( + `${shard.key}\n${shard.checkerDigest}\n${shard.interfaceFingerprint}\n${JSON.stringify(shard.graph)}`, + ); +} + +function resealSnapshot(snapshot: ICppGraphSnapshot): void { + for (const shard of snapshot.upserts) shard.digest = nativeShardDigest(shard); + const digests = new Map( + snapshot.upserts.map((shard) => [shard.key, shard.digest]), + ); + snapshot.manifest = snapshot.manifest.map((entry) => ({ + key: entry.key, + digest: digests.get(entry.key) ?? entry.digest, + })); + snapshot.generation = nativeGeneration( + snapshot.universe.digest, + snapshot.manifest, + ); +} + +function nativeGeneration( + universe: string, + manifest: readonly { key: string; digest: string }[], +): string { + return sha256( + universe + + manifest + .map( + (entry) => + `${Buffer.byteLength(entry.key, "utf8")}:${entry.key}${entry.digest}`, + ) + .join(""), + ); +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +async function rejected( + label: string, + promise: Promise, + message: string, +): Promise { + let error: Error | undefined; + try { + await promise; + } catch (caught) { + error = caught instanceof Error ? caught : new Error(String(caught)); + } + TestValidator.predicate( + label, + error !== undefined && + message.split("|").some((candidate) => error!.message.includes(candidate)), + ); +} diff --git a/tests/test-graph/src/features/test_cpp_static_preserves_out_of_line_method_flows.ts b/tests/test-graph/src/features/test_cpp_static_preserves_out_of_line_method_flows.ts index b116bf34..53cf17d5 100644 --- a/tests/test-graph/src/features/test_cpp_static_preserves_out_of_line_method_flows.ts +++ b/tests/test-graph/src/features/test_cpp_static_preserves_out_of_line_method_flows.ts @@ -6,10 +6,11 @@ import { buildGraphDump } from "@samchon/graph"; import { GraphPaths } from "../internal/GraphPaths"; +/** Proves shared headers retain their C++ structure and out-of-line flows. */ export const test_cpp_static_preserves_out_of_line_method_flows = async () => { const root = GraphPaths.createTempDirectory("samchon-cpp-methods-"); fs.writeFileSync( - path.join(root, "engine.hpp"), + path.join(root, "engine.h"), [ "namespace storage {", "struct Status {};", @@ -72,6 +73,39 @@ export const test_cpp_static_preserves_out_of_line_method_flows = async () => { mode: "static", languages: ["cpp"], }); + const automatic = await buildGraphDump({ cwd: root, mode: "static" }); + const edgeKeys = (edges: typeof graph.edges) => + edges.map((edge) => JSON.stringify(edge)).sort(); + TestValidator.equals( + "automatic ownership keeps every edge from the contextual C++ header view", + edgeKeys(automatic.edges), + edgeKeys(graph.edges), + ); + const cRoot = GraphPaths.createTempDirectory("samchon-c-header-owner-"); + fs.writeFileSync(path.join(cRoot, "record.h"), "struct Record { int value; };\n"); + fs.writeFileSync(path.join(cRoot, "record.c"), "int read_record(void) { return 0; }\n"); + const cGraph = await buildGraphDump({ cwd: cRoot, mode: "static" }); + TestValidator.predicate( + "an automatic C translation unit keeps its shared header in the C view", + cGraph.nodes.some( + (node) => node.file.endsWith("record.h") && node.language === "c", + ) && + cGraph.nodes.every( + (node) => !node.file.endsWith("record.h") || node.language !== "cpp", + ), + ); + const headerRoot = GraphPaths.createTempDirectory("samchon-header-owner-"); + fs.writeFileSync( + path.join(headerRoot, "standalone.h"), + "struct Standalone { int value; };\n", + ); + const headerGraph = await buildGraphDump({ cwd: headerRoot, mode: "static" }); + TestValidator.predicate( + "a header-only project retains the singular C compatibility owner", + headerGraph.nodes.some( + (node) => node.file.endsWith("standalone.h") && node.language === "c", + ), + ); const sourceMethods = graph.nodes.filter( (node) => node.file.endsWith("engine.cpp") && node.kind === "method", ); @@ -85,7 +119,7 @@ export const test_cpp_static_preserves_out_of_line_method_flows = async () => { const write = method("Write"); const engine = graph.nodes.find( (node) => - node.file.endsWith("engine.hpp") && + node.file.endsWith("engine.h") && node.kind === "class" && (node.qualifiedName ?? node.name) === "storage.Engine", ); @@ -122,7 +156,7 @@ export const test_cpp_static_preserves_out_of_line_method_flows = async () => { ["Put", "Get", "Write"].every((name) => graph.nodes.some( (node) => - node.file.endsWith("engine.hpp") && + node.file.endsWith("engine.h") && node.kind === "method" && node.name === name && node.qualifiedName === `storage.Engine.${name}`, diff --git a/tests/test-graph/src/features/test_ensure_compile_commands_wires_into_lsp_build.ts b/tests/test-graph/src/features/test_ensure_compile_commands_wires_into_lsp_build.ts index a5a6548c..ef512d1a 100644 --- a/tests/test-graph/src/features/test_ensure_compile_commands_wires_into_lsp_build.ts +++ b/tests/test-graph/src/features/test_ensure_compile_commands_wires_into_lsp_build.ts @@ -8,10 +8,16 @@ import { GraphPaths } from "../internal/GraphPaths"; const fakeCmake = [process.execPath, GraphPaths.fakeCmake]; +/** Proves generated compilation databases live for exactly their LSP session. */ export const test_ensure_compile_commands_wires_into_lsp_build = async () => { const root = GraphFixtures.createCmakeFixture(); fs.mkdirSync(path.join(root, "src")); + fs.mkdirSync(path.join(root, "include")); fs.writeFileSync(path.join(root, "src", "main.cc"), "int main() { return 0; }\n"); + fs.writeFileSync( + path.join(root, "include", "shared.h"), + "class SharedHeader {};\n", + ); const argsFile = path.join(root, "fake-lsp-args.json"); const previousArgsFile = process.env.SAMCHON_GRAPH_FAKE_LSP_ARGS_FILE; @@ -26,6 +32,12 @@ export const test_ensure_compile_commands_wires_into_lsp_build = async () => { cmakeCommand: fakeCmake, }); TestValidator.equals("cpp LSP build still succeeds", dump.indexer, "lsp"); + TestValidator.predicate( + "generic C++ LSP discovery opens a shared .h before semantic ownership resolution", + dump.nodes.some( + (node) => node.language === "cpp" && node.file.endsWith("shared.h"), + ), + ); const args = JSON.parse(fs.readFileSync(argsFile, "utf8")) as string[]; TestValidator.predicate( "the resolved compile_commands.json directory is passed to the server", diff --git a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts index 97393a82..1a2b9b8b 100644 --- a/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts +++ b/tests/test-graph/src/features/test_experiment_corpora_are_commit_pinned.ts @@ -1,5 +1,9 @@ import { TestValidator } from "@nestia/e2e"; -import { LANGUAGE_SPECS } from "@samchon/graph"; +import { + CPP_CLANG_PRODUCER_COMMIT, + LANGUAGE_SPECS, + RUST_GRAPH_PRODUCER_COMMIT, +} from "@samchon/graph"; import fs from "node:fs"; import path from "node:path"; @@ -25,10 +29,16 @@ export const test_experiment_corpora_are_commit_pinned = () => { [...catalog.matchAll(/strictProvider:\s*"[^"]+"/g)].length, [...catalog.matchAll(/lifecycle:\s*\{/g)].length, ); + const typescript = region( + catalog, + 'language: "typescript"', + 'language: "go"', + ); const python = region(catalog, 'language: "python"', 'language: "ruby"'); const java = region(catalog, 'language: "java"', 'language: "csharp"'); const csharp = region(catalog, 'language: "csharp"', 'language: "kotlin"'); const kotlin = region(catalog, 'language: "kotlin"', 'language: "swift"'); + const rust = region(catalog, 'language: "rust"', 'language: "cpp"'); const swift = region(catalog, 'language: "swift"', 'language: "scala"'); const scala = region(catalog, 'language: "scala"', 'language: "zig"'); const zig = region(catalog, 'language: "zig"', 'language: "python"'); @@ -40,11 +50,25 @@ export const test_experiment_corpora_are_commit_pinned = () => { const dart = region(catalog, 'language: "dart"', "\n];"); const javaSetup = region(setup, 'case "java"', 'case "csharp"'); const kotlinSetup = region(setup, 'case "kotlin"', 'case "swift"'); + const rustSetup = region(setup, 'case "rust"', 'case "cpp"'); + const cppSetup = region(setup, 'case "cpp"', 'case "java"'); TestValidator.equals( "every registered strict-provider language has a lifecycle row", [...catalog.matchAll(/strictProvider:\s*"[^"]+"/g)].length, 13, ); + TestValidator.predicate( + "Rust builds and records the exact native HIR producer declared by the catalog", + rust.includes('producerRepository: "https://github.com/samchon/rust-analyzer.git"') && + rust.includes(`producerCommit: "${RUST_GRAPH_PRODUCER_COMMIT}"`) && + rustSetup.includes("--default-toolchain 1.95.0") && + rustSetup.includes('"rust-src"') && + rustSetup.includes('["fetch", "--depth=1", "origin", experiment.producerCommit]') && + rustSetup.includes('["build", "--locked", "--release", "-p", "rust-analyzer"]') && + rustSetup.includes('for (const command of ["samchon-rust-analyzer", "rust-analyzer"])') && + rustSetup.includes("fs.linkSync(producerBinary, link)") && + !rustSetup.includes("rustup component add rust-analyzer"), + ); TestValidator.predicate( "the remaining SCIP providers use isolated upstream lifecycle projects", [java, kotlin, ruby, php, dart].every( @@ -150,72 +174,176 @@ export const test_experiment_corpora_are_commit_pinned = () => { ); TestValidator.predicate( "every producer with no grounded edge family states that limitation explicitly", - [csharp, cpp, c].every( - (row) => - row.includes("semanticEdges: []") && - !row.includes("crossFileEdge:") && - declares(row, "semanticLimitation"), - ) && + csharp.includes("semanticEdges: []") && + !csharp.includes("crossFileEdge:") && + declares(csharp, "semanticLimitation") && runner.includes("experiment.semanticEdges.length === 0") && runner.includes("crossFileEdge !== undefined") && runner.includes("semanticLimitation.trim() ==="), ); - // scip-python 0.6.6 recovers from a malformed `pyproject.toml` and emits no - // SCIP diagnostics, so a row claiming either boundary would assert behaviour - // the pinned producer does not have. A tolerated row earns its place only by - // proving the exact upstream claim instead — the producer ignored the input, - // so the build universe moved and the published facts did not — and by saying - // what it gave up rather than leaving a reader to infer it from a green lane. TestValidator.predicate( - "a failure boundary the producer does not have is published as a limitation", + "the native C and C++ producer grounds cross-file graph families", + [cpp, c].every( + (row) => + row.includes('strictProvider: "clangd-snapshot"') && + row.includes( + 'producerRepository: "https://github.com/samchon/llvm-project.git"', + ) && + row.includes(`producerCommit: "${CPP_CLANG_PRODUCER_COMMIT}"`) && + row.includes('crossFileEdge: "references"') && + row.includes('"contains"') && + row.includes('"references"') && + !row.includes('"implements"') && + !row.includes('"dispatches"') && + !row.includes("semanticEdges: []"), + ) && + !c.includes('"instantiates"') && + !c.includes('"extends"') && + !c.includes('"overrides"'), + ); + TestValidator.predicate( + "C and C++ build and record the exact campaign-owned native producer", + cppSetup.includes('apt(["clang", "cmake", "ninja-build", "bear"])') && + cppSetup.includes("installClangGraphProducer()") && + setup.includes( + '["fetch", "--depth=1", "origin", experiment.producerCommit]', + ) && + setup.includes('["checkout", "--detach", "FETCH_HEAD"]') && + setup.includes('["rev-parse", "HEAD"]') && + setup.includes('"-DLLVM_ENABLE_PROJECTS=clang;clang-tools-extra"') && + setup.includes('"--target",') && + setup.includes('"clangd",') && + setup.includes('for (const command of ["samchon-clangd", "clangd"])') && + setup.includes("fs.linkSync(binary, link)") && + setup.includes('path.join(build, "lib", "clang")') && + setup.includes("fs.cpSync(builtResources, installedResources") && + setup.includes('"include",') && + setup.includes('"stddef.h",') && + setup.includes("version.includes(experiment.producerCommit)") && + setup.includes("installedVersion.includes(experiment.producerCommit)") && + setup.includes('tool: "samchon-clangd"') && + !cppSetup.includes('apt(["clangd"'), + ); + // A fixed parallelism here already cost CI lanes, and the size of this build + // is the thing that has to stay correct, so pin the decision rather than its + // vocabulary. Naming `os.availableParallelism()` and `os.totalmem()` proves + // nothing on its own: a comment saying why they were abandoned contains both + // names, and a `const jobs = 2` under it would satisfy every such check. + // Comments are therefore stripped before anything is matched, and the + // binding is asserted end to end: the expression that computes the count, + // the argument that hands that same count to the build, and the log that + // makes it visible in a run. + // + // The configure call is inside this region too, and `-DLLVM_PARALLEL_*_JOBS` + // caps concurrency from there without touching `--build` at all. Watching + // only the build argv would leave that door open, so the region must set no + // such flag; if one is ever needed it has to be derived from `jobs` and this + // line has to change with it. + // + // This is a tripwire, not a proof. It refuses the regressions that have + // actually happened here and the nearest spellings of them; it cannot + // enumerate every way to reintroduce a constant. + const clangBuild = withoutLineComments( + region(setup, "const installClangGraphProducer", "const installScipPython"), + ); + TestValidator.equals( + "the native Clang build is sized by the machine and bounded by its memory", + [ + /const jobs = Math\.max\(\s*1,\s*Math\.min\(\s*os\.availableParallelism\(\),\s*Math\.floor\(os\.totalmem\(\) \/ \(2 \* 1024 \* 1024 \* 1024\)\),\s*\),\s*\);/u.test( + clangBuild, + ), + /"--parallel",\s*String\(jobs\),/u.test(clangBuild), + /"--parallel",\s*(?:"|`|'|\d)/u.test(clangBuild), + /LLVM_PARALLEL_[A-Z_]*JOBS/u.test(clangBuild), + /console\.log\([\s\S]*?String\(jobs\)/u.test(clangBuild), + ], + [true, true, false, false, true], + ); + // A restored producer is untrusted input, and the whole point of restoring + // it is to skip the build that would otherwise have proved what it is. So + // reuse is admitted by the same evidence a fresh build must produce: both + // installed names report the pinned commit, and the resource headers the + // adapter resolves relative to the binary are present exactly once. Anything + // short of that — a missing file, an unreadable tree, an unexpected version, + // any thrown error — falls back to building, because reuse is an + // optimisation and may only be taken on complete evidence. + // + // Bounded to the predicate's own body. An unbounded `[\s\S]*?` would let a + // deleted check pass by matching the identical text in the build path below + // it, so the region is what makes a deletion visible. + const restoredProducer = withoutLineComments( + region( + setup, + "const installedClangGraphProducer", + "const installClangGraphProducer", + ), + ); + TestValidator.equals( + "a restored native Clang producer is re-proved against the pin before reuse", + [ + setup.includes("if (installedClangGraphProducer()) return;"), + cppSetup.includes("installClangGraphProducer()"), + /String\(reported\.stdout\)\.includes\(\s*experiment\.producerCommit,?\s*\)/u.test( + restoredProducer, + ), + restoredProducer.includes('"stddef.h"'), + restoredProducer.includes("versions.length !== 1"), + /\} catch \{\s*\n\s*return false;/u.test(restoredProducer), + ], + [true, true, true, true, true, true], + ); + // scip-python 0.6.6 recovers from a malformed `pyproject.toml`, falls back to + // Pyright defaults and emits no SCIP diagnostics. On the pinned Click + // fixture, the source and semantic fact planes stay unchanged. The aggregate + // content digest is not evidence to the contrary because its legacy coverage + // target is the already-moved universe. + TestValidator.predicate( + "Python's malformed configuration is a tolerated unchanged publication", python.includes('failurePolicy: "tolerated"') && declares(python, "failureLimitation") && + python.includes("falling back to Pyright defaults") && lifecycle.includes('fixture.failurePolicy === "tolerated"') && - lifecycle.includes('fixture.failureLimitation === ""') && lifecycle.includes("provenance.universe === prior.universe") && - lifecycle.includes("provenance.content !== prior.content") && - lifecycle.includes("diagnosticCount !== previousDiagnostics"), + lifecycle.includes("publicationChanges(") && + lifecycle.includes("changed.length !== 0") && + lifecycle.includes("normalizedPublicationPlane(") && + !lifecycle.includes("provenance.content !== prior.content"), ); TestValidator.predicate( - "a degraded publication is distinct from an input the producer ignored", - lua.includes('failurePolicy: "published"') && - declares(lua, "failureLimitation") && + "a degraded publication is distinct from an unchanged tolerated one", + [csharp, lua].every( + (row) => + row.includes('failurePolicy: "published"') && + declares(row, "failureLimitation"), + ) && + python.includes('failurePolicy: "tolerated"') && + lifecycle.includes('status: "tolerated"') && lifecycle.includes('fixture.failurePolicy === "published"') && lifecycle.includes('status: "published-with-limitation"') && lifecycle.includes("publicationChanges("), ); TestValidator.predicate( - "a malformed compilation database proves strict decline and warned fallback", - [cpp, c].every( - (row) => - row.includes('failurePolicy: "fallback"') && - declares(row, "failureLimitation"), - ) && - lifecycle.includes('fixture.failurePolicy === "fallback"') && - lifecycle.includes('status: "fallback-with-limitation"') && - lifecycle.includes("row.provider === experiment.strictProvider") && - lifecycle.includes("warning.includes(experiment.strictProvider)") && - !lifecycle.includes("JSON.stringify(fallback)") && - lifecycle.includes('? ["initial", ...CHANGED_MODES]'), + "a regeneration failure names its first differing fact", + lifecycle.includes("firstGenerationDifference(cold, retried)") && + lifecycle.includes("first difference:") && + lifecycle.includes("normalized dump fact planes are equal"), + ); + TestValidator.predicate( + "a malformed compilation database rejects the native generation", + [cpp, c].every((row) => row.includes('failurePolicy: "reject"')) && + lifecycle.includes('fixture.failurePolicy === "reject"') && + lifecycle.includes('status: "rejected"'), ); - // Regenerating an unchanged project must reproduce it, and that assertion is - // the lifecycle's strongest. Exactly one registered producer cannot meet it: - // scip-clang 0.4.0 documents `--deterministic` as not scheduling work - // deterministically, and warns separately that non-determinism changes how - // many files each indexing job skips — which moves the source manifest as - // well as the facts, because the manifest lists the files it reported. The - // exemption is therefore a declared, explained property of those two rows - // rather than a relaxed default, and it covers one claim rather than two. + // Native C/C++ shards and manifests are canonical independently of + // background scheduling, so these rows keep the strongest reproduction + // assertion and carry no producer-specific exemption. TestValidator.predicate( - "an unreproducible producer is declared rather than serialized", - [cpp, c].every((row) => declares(row, "regenerationLimitation")) && - // Counted over the whole catalog, not checked against a list of the rows - // that happen not to declare it. The exemption drops the lifecycle's - // strongest assertion for whichever row carries it, so a third one - // appearing has to be a reviewed edit here rather than eight words in a - // catalog nobody re-reads. - [...catalog.matchAll(/regenerationLimitation:/g)].length === 2 && + "native C and C++ regeneration stays reproducible", + [cpp, c].every((row) => !declares(row, "regenerationLimitation")) && + // Counted over the whole catalog so any future reproduction exemption + // requires a reviewed contract change here. + [...catalog.matchAll(/regenerationLimitation:/g)].length === 0 && runner.includes("experiment.regenerationLimitation !== undefined") && runner.includes("regenerationLimitation.trim() === \"\"") && runner.includes( @@ -333,6 +461,30 @@ export const test_experiment_corpora_are_commit_pinned = () => { !runner.includes('experiment.strictAuthority ?? "compiler"') && !runner.includes("experiment.strictTool ?? experiment.strictProvider"), ); + TestValidator.predicate( + "the TypeScript published-release boundary launches before fallback selection", + typescript.includes("strictReleaseBoundary: {") && + typescript.includes('version: "0.23.0"') && + typescript.includes('warning: "legacy full dump"') && + typescript.includes("reason:") && + runner.includes( + "const strictDeclared = experiment.strictProvider !== undefined", + ) && + runner.includes( + "const releaseBoundary = experiment.strictReleaseBoundary", + ) && + runner.includes( + "const strict = strictDeclared && releaseBoundary === undefined", + ) && + runner.includes("...(releaseBoundary === undefined") && + runner.includes( + "? { lspReferenceLimit: experiment.referenceLimit ?? 250 }", + ) && + runner.includes("releaseBoundary !== undefined &&") && + runner.includes("declaredProvenance !== undefined") && + runner.includes("warning.includes(releaseBoundary.warning)") && + setup.includes("experiment.strictReleaseBoundary?.version"), + ); TestValidator.predicate( "the runner proves declared families are present and undeclared ones absent", runner.includes("provenance.facts.includes(kind)") && @@ -379,6 +531,22 @@ export const test_experiment_corpora_are_commit_pinned = () => { ); }; +/** + * One source region with its line comments removed. + * + * These files explain themselves at length, and every identifier an assertion + * looks for is also written in the prose around the code that uses it. Without + * this, `includes` and even a careful regex are satisfied by a comment + * describing the very thing that was deleted — which is how the first version + * of the parallelism pin passed against a hard-coded job count. + */ +function withoutLineComments(source: string): string { + return source + .split("\n") + .filter((line) => !/^\s*\/\//u.test(line)) + .join("\n"); +} + function experimentSource(file: string): string { return fs.readFileSync( path.join(GraphPaths.repositoryRoot, "tests", "experiment", "src", file), diff --git a/tests/test-graph/src/features/test_graph_dump_parser_closes_every_public_trust_boundary.ts b/tests/test-graph/src/features/test_graph_dump_parser_closes_every_public_trust_boundary.ts index b4344803..d353d332 100644 --- a/tests/test-graph/src/features/test_graph_dump_parser_closes_every_public_trust_boundary.ts +++ b/tests/test-graph/src/features/test_graph_dump_parser_closes_every_public_trust_boundary.ts @@ -1,5 +1,10 @@ import { TestValidator } from "@nestia/e2e"; -import { parseGraphDump, semanticGraphNodeId } from "@samchon/graph"; +import { + GRAPH_EDGE_KINDS, + ISamchonGraphDump, + parseGraphDump, + semanticGraphNodeId, +} from "@samchon/graph"; import path from "node:path"; const valid = () => ({ @@ -43,6 +48,16 @@ const valid = () => ({ }>, }); +/** + * `parseGraphDump` is the only gate between an untrusted dump file and every + * consumer that treats its facts as checked — the MCP server, the viewer, and + * the resident source all skip revalidation because this ran. A rule it fails + * to enforce is therefore not a parse error but a false claim downstream. This + * walks each boundary it owns with a negative twin: identity and path shape, + * edge endpoint closure, provenance uniqueness, exhaustive coverage per + * published slice, and unresolved sites bound to a partial row in their + * provider's own universe. + */ export const test_graph_dump_parser_closes_every_public_trust_boundary = async () => { const dump = valid(); @@ -92,10 +107,37 @@ export const test_graph_dump_parser_closes_every_public_trust_boundary = parseGraphDump(provenance).provenance?.[0]?.provider, "scip-go", ); + const trusted = withTrust(); + TestValidator.equals( + "exhaustive coverage and universe-bound uncertainty parse", + [ + parseGraphDump(trusted).coverage?.length, + parseGraphDump(trusted).unresolved?.[0]?.reason, + ], + [GRAPH_EDGE_KINDS.length, "dynamic"], + ); await rejected("duplicate node identities", (candidate) => { candidate.nodes.push({ ...candidate.nodes[1]! }); }); + await rejected("empty node identities", (candidate) => { + candidate.nodes[1]!.id = ""; + }); + await rejected("NUL-delimited node identities", (candidate) => { + candidate.nodes[1]!.id = "src/other.go\0#Other:function"; + }); + await rejected("empty node display names", (candidate) => { + candidate.nodes[1]!.name = ""; + }); + await rejected("NUL-delimited node display names", (candidate) => { + candidate.nodes[1]!.name = "Other\0Name"; + }); + await rejected("empty qualified names", (candidate) => { + candidate.nodes[0]!.qualifiedName = ""; + }); + await rejected("NUL-delimited qualified names", (candidate) => { + candidate.nodes[0]!.qualifiedName = "example\0Run"; + }); await rejected("relative project roots", (candidate) => { candidate.project = "fixture"; }); @@ -120,6 +162,10 @@ export const test_graph_dump_parser_closes_every_public_trust_boundary = await rejected("raw absolute graph paths", (candidate) => { record(candidate.nodes[1]!).file = "C:/machine/other.go"; }); + await rejected("NUL-delimited graph paths", (candidate) => { + candidate.nodes[1]!.id = "src/other\0name.go#Other:function"; + candidate.nodes[1]!.file = "src/other\0name.go"; + }); await rejected("terminal parent graph paths", (candidate) => { record(candidate.nodes[1]!).file = "../.."; }); @@ -129,6 +175,13 @@ export const test_graph_dump_parser_closes_every_public_trust_boundary = await rejected("backslashed bundled graph paths", (candidate) => { record(candidate.nodes[1]!).file = "bundled:///go\\..\\escape"; }); + await rejected("NUL-delimited bundled graph paths", (candidate) => { + candidate.nodes[1]!.id = "bundled:///go/\0builtin"; + candidate.nodes[1]!.kind = "file"; + candidate.nodes[1]!.name = "builtin"; + candidate.nodes[1]!.file = "bundled:///go/\0builtin"; + candidate.nodes[1]!.external = true; + }); await rejected("invalid source ranges", (candidate) => { candidate.nodes[0]!.evidence!.endLine = 0; }); @@ -230,6 +283,11 @@ export const test_graph_dump_parser_closes_every_public_trust_boundary = await rejected("empty provenance provider names", (candidate) => { candidate.provenance = [{ ...validProvenance(), provider: "" }]; }); + await rejected("NUL-delimited provenance provider names", (candidate) => { + candidate.provenance = [ + { ...validProvenance(), provider: "scip\0go" }, + ]; + }); await rejected("invalid provenance producer revisions", (candidate) => { candidate.provenance = [ { @@ -282,6 +340,74 @@ export const test_graph_dump_parser_closes_every_public_trust_boundary = candidate.provenance = [{ ...validProvenance(), [label]: "bad" }]; }); } + await rejectedTrust("empty coverage provider identities", (candidate) => { + candidate.coverage![0]!.provider = ""; + }); + await rejectedTrust( + "NUL-delimited coverage provider identities", + (candidate) => { + candidate.coverage![0]!.provider = "scip\0go"; + }, + ); + await rejectedTrust("empty coverage targets", (candidate) => { + candidate.coverage![0]!.target = ""; + }); + await rejectedTrust("NUL-delimited coverage targets", (candidate) => { + candidate.coverage![0]!.target = "fixture\0other"; + }); + await rejectedTrust("coverage languages absent from the dump", (candidate) => { + record(candidate.coverage![0]!).language = "rust"; + }); + await rejectedTrust("duplicate coverage rows", (candidate) => { + candidate.coverage!.push({ ...candidate.coverage![0]! }); + }); + await rejectedTrust("missing provider coverage", (candidate) => { + candidate.coverage = []; + candidate.unresolved = []; + }); + await rejectedTrust("non-exhaustive provider coverage", (candidate) => { + candidate.coverage!.pop(); + }); + await rejected( + "non-exhaustive fallback-only coverage", + (candidate) => { + candidate.coverage = GRAPH_EDGE_KINDS.slice(1).map((family) => ({ + provider: "@samchon/graph-lsp", + language: "go", + target: "fallback/default", + family, + state: "partial", + })); + candidate.unresolved = []; + }, + ); + await rejectedTrust("invalid unresolved evidence", (candidate) => { + candidate.unresolved![0]!.evidence.startLine = 0; + }); + await rejectedTrust("duplicate unresolved candidates", (candidate) => { + candidate.unresolved![0]!.candidates = ["candidate", "candidate"]; + }); + await rejectedTrust("malformed unresolved universes", (candidate) => { + candidate.unresolved![0]!.universe = "bad"; + }); + await rejectedTrust("unowned unresolved providers", (candidate) => { + candidate.unresolved![0]!.provider = "other"; + }); + await rejectedTrust("unresolved sites without provenance", (candidate) => { + candidate.provenance = undefined; + }); + await rejectedTrust("mismatched unresolved universes", (candidate) => { + candidate.unresolved![0]!.universe = "b".repeat(64); + }); + await rejectedTrust("unresolved sites without partial coverage", (candidate) => { + candidate.coverage!.find((row) => row.family === "calls")!.state = + "complete"; + }); + await rejectedTrust("duplicate unresolved sites", (candidate) => { + candidate.unresolved!.push( + structuredClone(candidate.unresolved![0]!), + ); + }); await rejected("semantic display suffix mismatches", (candidate) => { candidate.nodes[0]!.qualifiedName = "example.NotRun"; }); @@ -302,6 +428,8 @@ type Candidate = ReturnType & { message: string; }>; provenance?: Array>; + coverage?: NonNullable; + unresolved?: NonNullable; }; const rejected = async ( @@ -315,6 +443,17 @@ const rejected = async ( ); }; +const rejectedTrust = async ( + label: string, + mutate: (candidate: Candidate) => void, +): Promise => { + const candidate = withTrust(); + mutate(candidate); + await TestValidator.error(`${label} fail closed`, () => + parseGraphDump(candidate), + ); +}; + function withEdge(): Candidate { const candidate = valid(); candidate.edges.push({ @@ -325,6 +464,32 @@ function withEdge(): Candidate { return candidate as Candidate; } +function withTrust(): Candidate { + const candidate = withEdge(); + const provenance = validProvenance(); + candidate.provenance = [provenance]; + candidate.coverage = GRAPH_EDGE_KINDS.map((family) => ({ + provider: provenance.provider, + language: "go", + target: "fixture", + family, + state: family === "calls" ? "partial" : "unsupported", + })); + candidate.unresolved = [ + { + provider: provenance.provider, + language: "go", + target: "fixture", + universe: provenance.universe, + family: "calls", + evidence: { file: "src/run.go", startLine: 1, startCol: 1 }, + reason: "dynamic", + candidates: ["candidate"], + }, + ]; + return candidate; +} + function record(value: object): Record { return value as Record; } diff --git a/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts b/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts new file mode 100644 index 00000000..c49f75b2 --- /dev/null +++ b/tests/test-graph/src/features/test_graph_snapshot_protocol_commits_atomic_shard_generations.ts @@ -0,0 +1,1539 @@ +import { TestValidator } from "@nestia/e2e"; +import { + GRAPH_EDGE_KINDS, + GraphSnapshotProtocol, + IBulkGraphSession, + assertGraphSnapshotContract, + graphCoverageOf, + graphSnapshotDigests, + graphUnresolvedOf, +} from "@samchon/graph"; +import path from "node:path"; + +const digest = (letter: string): string => letter.repeat(64); + +function sequenceOf(generation: string): number { + const suffix = /-(\d+)$/u.exec(generation); + return suffix === null ? 4 : Number(suffix[1]); +} + +/** + * Graph Snapshot Protocol publishes one validated complete generation or keeps + * the prior one byte-for-byte. The fixture is an external producer oracle: all + * digests are computed from the public protocol helpers, never copied from the + * store under test. + */ +export const test_graph_snapshot_protocol_commits_atomic_shard_generations = + async () => { + const store = new GraphSnapshotProtocol.Store(process.cwd()); + const initialFrames = transaction("generation-1"); + const ndjson = initialFrames.map(JSON.stringify).join("\n"); + const parsed = GraphSnapshotProtocol.parse(ndjson); + let validations = 0; + const initial = store.apply(parsed, { + warnings: ["fixture host warning"], + validate: () => { + validations += 1; + }, + }); + const provider = { + name: "fixture-compiler", + authority: "compiler" as const, + facts: ["calls" as const], + }; + assertGraphSnapshotContract( + initial, + provider, + ["typescript"], + process.cwd(), + ); + + TestValidator.equals( + "the committed generation reconstructs every protocol plane", + [ + initial.protocol?.sequence, + initial.protocol?.generation, + initial.protocol?.manifest, + initial.protocol?.shards.map((shard) => shard.key), + initial.nodes.map((node) => node.name), + initial.coverage?.length, + initial.unresolved?.map((site) => site.reason), + initial.warnings, + validations, + ], + [ + 1, + "generation-1", + GraphSnapshotProtocol.manifestDigest([ + { + file: path.resolve("src/main.ts"), + checkerDigest: digest("c"), + diskDigest: digest("c"), + }, + ]), + ["coverage", "source"], + ["run"], + GRAPH_EDGE_KINDS.length, + ["dynamic", "reflection"], + ["fixture host warning"], + 1, + ], + ); + TestValidator.equals( + "protocol-aware helpers preserve explicit coverage and uncertainty", + [ + graphCoverageOf(initial).length, + graphUnresolvedOf(initial).length, + graphSnapshotDigests.contentOf(initial).length, + GraphSnapshotProtocol.factDigest({ + languages: initial.languages, + nodes: initial.nodes, + edges: initial.edges, + diagnostics: initial.diagnostics, + provenance: initial.provenance, + }).length, + ], + [GRAPH_EDGE_KINDS.length, 2, 64, 64], + ); + const sharedInput = { + file: path.resolve("src/main.ts"), + checkerDigest: digest("c"), + diskDigest: digest("c"), + }; + TestValidator.equals( + "a shared configuration or dependency input has one manifest identity", + GraphSnapshotProtocol.manifestDigest([sharedInput, sharedInput]), + GraphSnapshotProtocol.manifestDigest([sharedInput]), + ); + TestValidator.error( + "a shared input with conflicting digests is refused", + () => + GraphSnapshotProtocol.manifestDigest([ + sharedInput, + { ...sharedInput, diskDigest: digest("d") }, + ]), + ); + const sharedFrames = transaction("shared-input"); + coverageShard(sharedFrames).shard.sources.push({ ...sharedInput }); + refreshDigests(sharedFrames); + TestValidator.equals( + "two shards can share one byte-identical input", + new GraphSnapshotProtocol.Store(process.cwd()).apply(sharedFrames).sources + .size, + 1, + ); + for (const [label, expected, mutateSnapshot] of invalidProtocolSnapshots()) { + let message = ""; + try { + const candidate = cloneSnapshot(initial); + mutateSnapshot(candidate); + assertGraphSnapshotContract( + candidate, + provider, + ["typescript"], + process.cwd(), + ); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + TestValidator.predicate( + `${label} fails at the protocol publication gate: ${message}`, + message.includes(expected), + ); + } + TestValidator.error("a published generation is deeply immutable", () => { + initial.nodes.push({ ...initial.nodes[0]! }); + }); + + const editedFrames = transaction("generation-2", { + baseGeneration: "generation-1", + baseSequence: 1, + nodeName: "edited", + }); + const edited = store.apply(editedFrames); + TestValidator.equals( + "a delta reuses unchanged shards and replaces only its upsert", + [ + edited.nodes.map((node) => node.name), + edited.protocol?.baseSequence, + edited.protocol?.baseGeneration, + edited.protocol?.shards[0]?.digest === + initial.protocol?.shards[0]?.digest, + ], + [["edited"], 1, "generation-1", true], + ); + + const deleted = store.apply( + transaction("generation-3", { + baseGeneration: "generation-2", + baseSequence: 2, + deleteSource: true, + }), + ); + TestValidator.equals( + "an explicit delete removes the shard without disturbing coverage", + [deleted.nodes, deleted.coverage?.length], + [[], GRAPH_EDGE_KINDS.length], + ); + + await rejectedWithoutMovement( + store, + transaction("stale", { + sequence: 4, + baseSequence: 1, + baseGeneration: "generation-1", + }), + "a stale base", + ); + await rejectedWithoutMovement( + store, + mutate( + transaction("changed-identity", { + baseGeneration: "generation-3", + baseSequence: 3, + coverageState: "complete", + }), + (frames) => { + (frames[0] as GraphSnapshotProtocol.IHello).producer = "other"; + }, + ), + "producer identity movement across a delta", + ); + await rejectedWithoutMovement( + store, + transaction("missing-delete", { + baseGeneration: "generation-3", + baseSequence: 3, + deleteSource: true, + }), + "deleting an absent shard", + ); + const manifestWithoutDelta = mutate( + transaction("manifest-without-delta", { + baseGeneration: "generation-3", + baseSequence: 3, + deleteSource: true, + }), + (frames) => { + (frames[1] as GraphSnapshotProtocol.IBegin).manifest = digest("d"); + frames.splice(2, 2); + }, + ); + await rejectedWithoutMovement( + store, + manifestWithoutDelta, + "manifest movement without a shard delta", + ); + await rejectedWithoutMovement( + store, + transaction("generation-3", { sequence: 3 }), + "a non-advancing generation sequence", + ); + let validationError = ""; + try { + store.apply( + transaction("validator-rejected-4", { + baseGeneration: "generation-3", + baseSequence: 3, + coverageState: "complete", + }), + { + validate: () => { + throw new Error("fixture publication refusal"); + }, + }, + ); + } catch (error) { + validationError = + error instanceof Error ? error.message : String(error); + } + TestValidator.predicate( + "a host publication refusal keeps the prior committed generation", + validationError.includes("fixture publication refusal") && + store.current === deleted, + ); + await rejectedWithoutMovement( + store, + mutate( + transaction("moved-universe", { + baseGeneration: "generation-3", + baseSequence: 3, + coverageState: "complete", + }), + (frames) => { + (frames[1] as GraphSnapshotProtocol.IBegin).universe = digest("d"); + }, + ), + "universe movement retaining an untouched shard", + ); + await rejectedWithoutMovement( + store, + mutate( + transaction("moved-target", { + baseGeneration: "generation-3", + baseSequence: 3, + coverageState: "complete", + }), + (frames) => { + (frames[1] as GraphSnapshotProtocol.IBegin).targets.push("other"); + }, + ), + "target movement retaining an untouched shard", + ); + await rejectedWithoutMovement( + store, + mutate( + transaction("bad-shard", { + baseGeneration: "generation-3", + baseSequence: 3, + coverageState: "complete", + }), + (frames) => { + upsert(frames, "source").digest = digest("f"); + }, + ), + "a shard digest mismatch", + ); + await rejectedWithoutMovement( + store, + mutate( + transaction("bad-facts", { + baseGeneration: "generation-3", + baseSequence: 3, + coverageState: "complete", + }), + (frames) => { + commit(frames).factDigest = digest("f"); + }, + ), + "a fact digest mismatch", + ); + await rejectedWithoutMovement( + store, + mutate( + transaction("bad-manifest", { + baseGeneration: "generation-3", + baseSequence: 3, + coverageState: "complete", + }), + (frames) => { + commit(frames).shards.reverse(); + }, + ), + "a non-canonical manifest", + ); + await rejectedWithoutMovement( + store, + mutate(transaction("missing-coverage"), (frames) => { + coverageShard(frames).shard.coverage.pop(); + refreshDigests(frames); + }), + "a missing coverage family", + ); + await rejectedWithoutMovement( + store, + mutate(transaction("wrong-owner"), (frames) => { + coverageShard(frames).shard.coverage[0]!.provider = "other"; + refreshDigests(frames); + }), + "foreign coverage ownership", + ); + await rejectedWithoutMovement( + store, + mutate(transaction("wrong-uncertainty"), (frames) => { + coverageShard(frames).shard.coverage.find( + (row) => row.family === "calls", + )!.state = "complete"; + refreshDigests(frames); + }), + "an unresolved site without partial coverage", + ); + await rejectedWithoutMovement( + store, + mutate(transaction("missing-uncertainty"), (frames) => { + coverageShard(frames).shard.unresolved = []; + refreshDigests(frames); + }), + "partial coverage without unresolved evidence", + ); + await rejectedWithoutMovement( + store, + mutate(transaction("wrong-universe"), (frames) => { + coverageShard(frames).shard.unresolved[0]!.universe = digest("d"); + refreshDigests(frames); + }), + "an unresolved site from another universe", + ); + + const aborted = new AbortController(); + aborted.abort(); + await rejectedWithoutMovement( + store, + transaction("aborted"), + "an aborted transaction", + aborted.signal, + ); + TestValidator.error("an empty stream is rejected", () => + GraphSnapshotProtocol.parse(""), + ); + TestValidator.error("a blank NDJSON frame is rejected", () => + GraphSnapshotProtocol.parse("{}\n"), + ); + TestValidator.error("malformed NDJSON is rejected", () => + GraphSnapshotProtocol.parse("{"), + ); + + for (const [label, frames] of malformedTransactions()) { + await rejectedWithoutMovement(store, frames, label); + } + + const manifestStore = new GraphSnapshotProtocol.Store(process.cwd()); + manifestStore.apply(transaction("manifest-generation-1")); + const manifestEdit = transaction("manifest-generation-2", { + baseGeneration: "manifest-generation-1", + baseSequence: 1, + nodeName: "manifest-edited", + sourceDigest: "d", + }); + TestValidator.equals( + "manifest movement commits when a shard delta carries the affected facts", + manifestStore.apply(manifestEdit).nodes.map((node) => node.name), + ["manifest-edited"], + ); + const replayedManifestShard = mutate( + transaction("manifest-generation-3", { + baseGeneration: "manifest-generation-2", + baseSequence: 2, + nodeName: "manifest-edited", + sourceDigest: "d", + }), + (frames) => { + (frames[1] as GraphSnapshotProtocol.IBegin).manifest = digest("e"); + }, + ); + await rejectedWithoutMovement( + manifestStore, + replayedManifestShard, + "manifest movement disguised as a byte-identical shard upsert", + ); + const replayedUniverseShard = mutate( + transaction("manifest-generation-3", { + baseGeneration: "manifest-generation-2", + baseSequence: 2, + nodeName: "manifest-edited", + sourceDigest: "d", + }), + (frames) => { + (frames[1] as GraphSnapshotProtocol.IBegin).universe = digest("d"); + }, + ); + await rejectedWithoutMovement( + manifestStore, + replayedUniverseShard, + "universe movement disguised as a byte-identical shard upsert", + ); + + const boundedGenerationStore = new GraphSnapshotProtocol.Store(process.cwd()); + boundedGenerationStore.apply(transaction("bounded-generation-1")); + boundedGenerationStore.apply( + transaction("bounded-generation-2", { + baseGeneration: "bounded-generation-1", + baseSequence: 1, + nodeName: "second", + }), + ); + const returnedToken = boundedGenerationStore.apply( + transaction("bounded-generation-1", { + sequence: 3, + baseGeneration: "bounded-generation-2", + baseSequence: 2, + nodeName: "third", + }), + ); + TestValidator.equals( + "a bounded generation pair can reuse an old spelling without retaining token history", + [returnedToken.protocol?.sequence, returnedToken.protocol?.generation], + [3, "bounded-generation-1"], + ); + await rejectedWithoutMovement( + boundedGenerationStore, + transaction("stale-after-aba", { + sequence: 2, + baseGeneration: "bounded-generation-1", + baseSequence: 1, + nodeName: "stale", + }), + "an obsolete sequence cannot exploit a repeated generation spelling", + ); + + const deleteStore = new GraphSnapshotProtocol.Store(process.cwd()); + deleteStore.apply(transaction("delete-generation-1")); + const duplicateDelete = transaction("delete-generation-2", { + baseGeneration: "delete-generation-1", + baseSequence: 1, + deleteSource: true, + }); + const deleteFrame = duplicateDelete.find( + (frame): frame is GraphSnapshotProtocol.IDeleteShard => + frame.type === "deleteShard", + )!; + duplicateDelete.splice( + -1, + 0, + structuredClone(deleteFrame), + ); + await rejectedWithoutMovement( + deleteStore, + duplicateDelete, + "a duplicate delete delta", + ); + + const bundledStore = new GraphSnapshotProtocol.Store(process.cwd()); + const bundledFrames = mutate( + transaction("bundled-generation"), + (frames) => { + const shard = upsert(frames, "source").shard; + const file = "bundled:///typescript/lib.d.ts"; + Object.assign(shard.nodes[0]!, { + id: file, + kind: "file", + name: "lib.d.ts", + file, + external: true, + }); + shard.sources[0]!.file = file; + for (const site of coverageShard(frames).shard.unresolved) { + site.evidence.file = file; + if (site.candidates !== undefined) site.candidates = [file]; + } + refreshDigests(frames); + }, + ); + TestValidator.equals( + "a canonical bundled source identity commits", + [...bundledStore.apply(bundledFrames).sources.keys()], + ["bundled:///typescript/lib.d.ts"], + ); + }; + +interface ITransactionOptions { + sequence?: number; + baseSequence?: number; + baseGeneration?: string; + coverageState?: "complete" | "partial"; + nodeName?: string; + sourceDigest?: string; + deleteSource?: boolean; +} + +function transaction( + generation: string, + options: ITransactionOptions = {}, +): GraphSnapshotProtocol.Frame[] { + const hello = validHello(); + const begin: GraphSnapshotProtocol.IBegin = { + type: "begin", + sequence: options.sequence ?? sequenceOf(generation), + generation, + ...(options.baseGeneration !== undefined + ? { + baseSequence: + options.baseSequence ?? sequenceOf(options.baseGeneration), + baseGeneration: options.baseGeneration, + } + : {}), + universe: digest("a"), + manifest: digest("pending manifest"), + targets: ["app"], + }; + const coverage: GraphSnapshotProtocol.IShard = { + key: "coverage", + target: "app", + languages: ["typescript"], + nodes: [], + edges: [], + diagnostics: [], + coverage: GRAPH_EDGE_KINDS.map((family) => ({ + provider: hello.provider, + language: "typescript", + target: "app", + family, + state: + family === "calls" + ? options.coverageState ?? + (options.deleteSource === true ? "complete" : "partial") + : "unsupported", + })), + unresolved: + (options.coverageState ?? + (options.deleteSource === true ? "complete" : "partial")) === + "complete" + ? [] + : [ + { + provider: hello.provider, + language: "typescript", + target: "app", + universe: begin.universe, + family: "calls", + evidence: { file: "src/main.ts", startLine: 1, startCol: 1 }, + reason: "dynamic", + candidates: ["src/main.ts#target:function"], + }, + { + provider: hello.provider, + language: "typescript", + target: "app", + universe: begin.universe, + family: "calls", + evidence: { file: "src/main.ts", startLine: 2, startCol: 1 }, + reason: "reflection", + }, + ], + sources: [], + }; + const source: GraphSnapshotProtocol.IShard = { + key: "source", + target: "app", + languages: ["typescript"], + nodes: [ + { + id: "src/main.ts#run:function", + kind: "function", + language: "typescript", + name: options.nodeName ?? "run", + file: "src/main.ts", + external: false, + }, + ], + edges: [], + diagnostics: [], + coverage: [], + unresolved: [], + sources: [ + { + file: path.resolve("src/main.ts"), + checkerDigest: digest(options.sourceDigest ?? "c"), + diskDigest: digest(options.sourceDigest ?? "c"), + }, + ], + }; + const upserts: GraphSnapshotProtocol.IUpsertShard[] = + options.baseGeneration === undefined + ? [upsertOf(coverage), ...(options.deleteSource === true ? [] : [upsertOf(source)])] + : options.deleteSource === true + ? [upsertOf(coverage)] + : [upsertOf(source)]; + const middle: GraphSnapshotProtocol.Frame[] = [ + ...upserts, + ...(options.deleteSource === true + ? [{ type: "deleteShard" as const, key: "source" }] + : []), + ]; + const retained = new Map(); + retained.set("coverage", coverage); + if (options.deleteSource !== true) retained.set("source", source); + const manifest = [...retained] + .sort(([left], [right]) => Number(left > right) - Number(left < right)) + .map(([key, shard]) => ({ + key, + digest: GraphSnapshotProtocol.shardDigest(shard), + })); + begin.manifest = GraphSnapshotProtocol.manifestDigest( + [...retained.values()].flatMap((shard) => shard.sources), + ); + const snapshot = snapshotOf(hello, begin, [...retained.values()]); + return [ + hello, + begin, + ...middle, + { + type: "commit", + sequence: begin.sequence, + generation, + shards: manifest, + factDigest: GraphSnapshotProtocol.factDigest(snapshot), + }, + ]; +} + +function snapshotOf( + hello: GraphSnapshotProtocol.IHello, + begin: GraphSnapshotProtocol.IBegin, + shards: readonly GraphSnapshotProtocol.IShard[], +): Pick< + IBulkGraphSession.ISnapshot, + | "languages" + | "nodes" + | "edges" + | "diagnostics" + | "coverage" + | "unresolved" + | "provenance" +> { + return { + languages: [...hello.languages], + nodes: shards.flatMap((shard) => shard.nodes), + edges: shards.flatMap((shard) => shard.edges), + diagnostics: shards.flatMap((shard) => shard.diagnostics), + coverage: shards.flatMap((shard) => shard.coverage), + unresolved: shards.flatMap((shard) => shard.unresolved), + provenance: { + provider: hello.provider, + authority: hello.authority, + facts: [...hello.supportedFacts], + schemaVersion: hello.producerSchemaVersion, + tool: hello.producer, + toolVersion: hello.producerVersion, + compilerVersion: hello.compilerVersion, + protocolVersion: hello.protocolVersion, + universe: begin.universe, + capabilities: [...hello.capabilities], + }, + }; +} + +function validHello(): GraphSnapshotProtocol.IHello { + return { + type: "hello", + protocolVersion: 1, + schemaVersion: GraphSnapshotProtocol.SCHEMA_VERSION, + producerSchemaVersion: 1, + provider: "fixture-compiler", + producer: "fixture-exporter", + producerVersion: "1.0.0", + compilerVersion: "fixture-1", + languages: ["typescript"], + authority: "compiler", + supportedFacts: ["calls"], + capabilities: [ + "universe", + "sourceDigests", + "diskDigests", + "shards", + "deltas", + ], + }; +} + +function upsertOf( + shard: GraphSnapshotProtocol.IShard, +): GraphSnapshotProtocol.IUpsertShard { + return { + type: "upsertShard", + digest: GraphSnapshotProtocol.shardDigest(shard), + shard, + }; +} + +function mutate( + frames: GraphSnapshotProtocol.Frame[], + operation: (frames: GraphSnapshotProtocol.Frame[]) => void, +): GraphSnapshotProtocol.Frame[] { + const cloned = structuredClone(frames); + operation(cloned); + return cloned; +} + +function refreshDigests( + frames: GraphSnapshotProtocol.Frame[], + options: { manifest?: boolean } = {}, +): void { + const hello = frames[0] as GraphSnapshotProtocol.IHello; + const begin = frames[1] as GraphSnapshotProtocol.IBegin; + const shards = frames + .filter( + (frame): frame is GraphSnapshotProtocol.IUpsertShard => + frame.type === "upsertShard", + ) + .map((frame) => { + frame.digest = GraphSnapshotProtocol.shardDigest(frame.shard); + return frame.shard; + }); + const last = commit(frames); + if (options.manifest !== false) { + begin.manifest = GraphSnapshotProtocol.manifestDigest( + shards.flatMap((shard) => shard.sources), + ); + } + last.shards = shards + .map((shard) => ({ + key: shard.key, + digest: GraphSnapshotProtocol.shardDigest(shard), + })) + .sort((left, right) => Number(left.key > right.key) - Number(left.key < right.key)); + last.factDigest = GraphSnapshotProtocol.factDigest( + snapshotOf(hello, begin, shards), + ); +} + +function commit( + frames: GraphSnapshotProtocol.Frame[], +): GraphSnapshotProtocol.ICommit { + return frames.at(-1) as GraphSnapshotProtocol.ICommit; +} + +function coverageShard( + frames: GraphSnapshotProtocol.Frame[], +): GraphSnapshotProtocol.IUpsertShard { + return upsert(frames, "coverage"); +} + +function upsert( + frames: GraphSnapshotProtocol.Frame[], + key: string, +): GraphSnapshotProtocol.IUpsertShard { + return frames.find( + (frame): frame is GraphSnapshotProtocol.IUpsertShard => + frame.type === "upsertShard" && frame.shard.key === key, + )!; +} + +function malformedTransactions(): Array< + [string, GraphSnapshotProtocol.Frame[]] +> { + const valid = transaction("malformed"); + return [ + ["an incomplete transaction", valid.slice(0, 2)], + [ + "a transaction not starting with hello", + [{ type: "deleteShard", key: "x" }, ...valid.slice(1)], + ], + [ + "a transaction without begin second", + [valid[0]!, { type: "deleteShard", key: "x" }, ...valid.slice(2)], + ], + [ + "a transaction not ending in commit", + [...valid.slice(0, -1), { type: "deleteShard", key: "x" }], + ], + [ + "an unknown protocol version", + mutate(valid, (frames) => { + record(frames[0]!).protocolVersion = 2; + }), + ], + [ + "an unknown schema version", + mutate(valid, (frames) => { + record(frames[0]!).schemaVersion = 2; + }), + ], + [ + "an invalid producer schema version", + mutate(valid, (frames) => { + record(frames[0]!).producerSchemaVersion = 0; + }), + ], + [ + "an input manifest digest mismatch", + mutate(valid, (frames) => { + record(frames[1]!).manifest = digest("f"); + }), + ], + [ + "duplicate hello languages", + mutate(valid, (frames) => { + record(frames[0]!).languages = ["typescript", "typescript"]; + }), + ], + [ + "an empty hello language set", + mutate(valid, (frames) => { + record(frames[0]!).languages = []; + }), + ], + [ + "an unknown hello language", + mutate(valid, (frames) => { + record(frames[0]!).languages = ["future-language"]; + }), + ], + [ + "duplicate advertised facts", + mutate(valid, (frames) => { + record(frames[0]!).supportedFacts = ["calls", "calls"]; + }), + ], + [ + "an unknown advertised fact", + mutate(valid, (frames) => { + record(frames[0]!).supportedFacts = ["future-fact"]; + }), + ], + [ + "an unknown provider authority", + mutate(valid, (frames) => { + record(frames[0]!).authority = "future-authority"; + }), + ], + [ + "duplicate advertised capabilities", + mutate(valid, (frames) => { + record(frames[0]!).capabilities = ["universe", "universe"]; + }), + ], + [ + "an empty advertised capability", + mutate(valid, (frames) => { + record(frames[0]!).capabilities = ["universe", ""]; + }), + ], + [ + "an empty provider identity", + mutate(valid, (frames) => { + record(frames[0]!).provider = ""; + }), + ], + [ + "a NUL producer identity", + mutate(valid, (frames) => { + record(frames[0]!).producer = "bad\0producer"; + }), + ], + [ + "a fractional begin sequence", + mutate(valid, (frames) => { + record(frames[1]!).sequence = 1.5; + record(commit(frames)).sequence = 1.5; + }), + ], + [ + "a non-positive begin sequence", + mutate(valid, (frames) => { + record(frames[1]!).sequence = 0; + record(commit(frames)).sequence = 0; + }), + ], + [ + "a base generation without its sequence", + mutate(valid, (frames) => { + record(frames[1]!).baseGeneration = "base"; + }), + ], + [ + "a base sequence without its generation", + mutate(valid, (frames) => { + record(frames[1]!).baseSequence = 3; + }), + ], + [ + "a fractional base sequence", + mutate(valid, (frames) => { + record(frames[1]!).baseSequence = 1.5; + record(frames[1]!).baseGeneration = "base"; + }), + ], + [ + "a non-positive base sequence", + mutate(valid, (frames) => { + record(frames[1]!).baseSequence = 0; + record(frames[1]!).baseGeneration = "base"; + }), + ], + [ + "a base sequence not older than its generation", + mutate(valid, (frames) => { + record(frames[1]!).baseSequence = 4; + record(frames[1]!).baseGeneration = "base"; + }), + ], + [ + "a NUL base generation", + mutate(valid, (frames) => { + record(frames[1]!).baseSequence = 3; + record(frames[1]!).baseGeneration = "bad\0base"; + }), + ], + [ + "an empty base generation", + mutate(valid, (frames) => { + record(frames[1]!).baseSequence = 3; + record(frames[1]!).baseGeneration = ""; + }), + ], + [ + "a malformed universe digest", + mutate(valid, (frames) => { + record(frames[1]!).universe = "bad"; + }), + ], + [ + "a malformed manifest digest", + mutate(valid, (frames) => { + record(frames[1]!).manifest = "bad"; + }), + ], + [ + "duplicate targets", + mutate(valid, (frames) => { + record(frames[1]!).targets = ["app", "app"]; + }), + ], + [ + "an empty target set", + mutate(valid, (frames) => { + record(frames[1]!).targets = []; + }), + ], + [ + "an empty target identity", + mutate(valid, (frames) => { + record(frames[1]!).targets = [""]; + }), + ], + [ + "a mismatched commit sequence", + mutate(valid, (frames) => { + commit(frames).sequence += 1; + }), + ], + [ + "a mismatched commit generation", + mutate(valid, (frames) => { + commit(frames).generation = "other"; + }), + ], + [ + "an unknown target", + mutate(valid, (frames) => { + coverageShard(frames).shard.target = "other"; + refreshDigests(frames); + }), + ], + [ + "an empty shard language set", + mutate(valid, (frames) => { + coverageShard(frames).shard.languages = []; + refreshDigests(frames); + }), + ], + [ + "a foreign shard language", + mutate(valid, (frames) => { + record(coverageShard(frames).shard).languages = ["go"]; + refreshDigests(frames); + }), + ], + [ + "a duplicated node inside one shard", + mutate(valid, (frames) => { + const shard = upsert(frames, "source").shard; + shard.nodes.push(structuredClone(shard.nodes[0]!)); + refreshDigests(frames); + }), + ], + [ + "a foreign-language node", + mutate(valid, (frames) => { + record(upsert(frames, "source").shard.nodes[0]!).language = "go"; + refreshDigests(frames); + }), + ], + [ + "an empty node display name", + mutate(valid, (frames) => { + upsert(frames, "source").shard.nodes[0]!.name = ""; + refreshDigests(frames); + }), + ], + [ + "an invalid node evidence span", + mutate(valid, (frames) => { + upsert(frames, "source").shard.nodes[0]!.evidence = { + startLine: 0, + }; + refreshDigests(frames); + }), + ], + [ + "node evidence absent from the source manifest", + mutate(valid, (frames) => { + upsert(frames, "source").shard.nodes[0]!.evidence = { + file: "src/missing.ts", + startLine: 1, + }; + refreshDigests(frames); + }), + ], + [ + "an edge from an unadvertised family", + mutate(valid, (frames) => { + const shard = upsert(frames, "source").shard; + shard.edges.push({ + kind: "type_ref", + from: shard.nodes[0]!.id, + to: shard.nodes[0]!.id, + }); + refreshDigests(frames); + }), + ], + [ + "an unknown edge family", + mutate(valid, (frames) => { + const shard = upsert(frames, "source").shard; + shard.edges.push({ + kind: "calls", + from: shard.nodes[0]!.id, + to: shard.nodes[0]!.id, + }); + record(shard.edges[0]!).kind = "future-fact"; + refreshDigests(frames); + }), + ], + [ + "a duplicated edge inside one shard", + mutate(valid, (frames) => { + const shard = upsert(frames, "source").shard; + const edge = { + kind: "calls" as const, + from: shard.nodes[0]!.id, + to: shard.nodes[0]!.id, + }; + shard.edges.push(edge, { ...edge }); + refreshDigests(frames); + }), + ], + [ + "a duplicated source inside one shard", + mutate(valid, (frames) => { + const shard = upsert(frames, "source").shard; + shard.sources.push({ ...shard.sources[0]! }); + refreshDigests(frames); + }), + ], + [ + "a malformed source digest", + mutate(valid, (frames) => { + upsert(frames, "source").shard.sources[0]!.checkerDigest = "bad"; + refreshDigests(frames); + }), + ], + [ + "a relative source identity", + mutate(valid, (frames) => { + upsert(frames, "source").shard.sources[0]!.file = "src/main.ts"; + refreshDigests(frames); + }), + ], + [ + "a non-canonical bundled source identity", + mutate(valid, (frames) => { + upsert(frames, "source").shard.sources[0]!.file = + "bundled:///typescript/../lib"; + refreshDigests(frames); + }), + ], + [ + "shards disagreeing about source bytes", + mutate(valid, (frames) => { + coverageShard(frames).shard.sources.push({ + file: path.resolve("src/main.ts"), + checkerDigest: digest("d"), + diskDigest: digest("d"), + }); + refreshDigests(frames, { manifest: false }); + }), + ], + [ + "shards disagreeing only about disk bytes", + mutate(valid, (frames) => { + coverageShard(frames).shard.sources.push({ + file: path.resolve("src/main.ts"), + checkerDigest: digest("c"), + diskDigest: digest("d"), + }); + refreshDigests(frames, { manifest: false }); + }), + ], + [ + "duplicate coverage rows", + mutate(valid, (frames) => { + const shard = coverageShard(frames).shard; + shard.coverage.push({ ...shard.coverage[0]! }); + refreshDigests(frames); + }), + ], + [ + "an unknown coverage family", + mutate(valid, (frames) => { + record(coverageShard(frames).shard.coverage[0]!).family = + "future-fact"; + refreshDigests(frames); + }), + ], + [ + "an unknown coverage state", + mutate(valid, (frames) => { + record(coverageShard(frames).shard.coverage[0]!).state = "unknown"; + refreshDigests(frames); + }), + ], + [ + "an unadvertised partial family", + mutate(valid, (frames) => { + coverageShard(frames).shard.coverage.find( + (row) => row.family === "contains", + )!.state = "partial"; + refreshDigests(frames); + }), + ], + [ + "duplicate unresolved sites", + mutate(valid, (frames) => { + const shard = coverageShard(frames).shard; + shard.unresolved.push(structuredClone(shard.unresolved[0]!)); + refreshDigests(frames); + }), + ], + [ + "an unknown unresolved reason", + mutate(valid, (frames) => { + record(coverageShard(frames).shard.unresolved[0]!).reason = "unknown"; + refreshDigests(frames); + }), + ], + [ + "duplicate unresolved candidates", + mutate(valid, (frames) => { + coverageShard(frames).shard.unresolved[0]!.candidates = [ + "candidate", + "candidate", + ]; + refreshDigests(frames); + }), + ], + [ + "a node duplicated across shards", + mutate(valid, (frames) => { + coverageShard(frames).shard.nodes.push( + structuredClone(upsert(frames, "source").shard.nodes[0]!), + ); + refreshDigests(frames); + }), + ], + [ + "an edge duplicated across shards", + mutate(valid, (frames) => { + const source = upsert(frames, "source").shard; + const edge = { + kind: "calls" as const, + from: source.nodes[0]!.id, + to: source.nodes[0]!.id, + }; + source.edges.push(edge); + coverageShard(frames).shard.edges.push({ ...edge }); + refreshDigests(frames); + }), + ], + [ + "an edge with an absent endpoint", + mutate(valid, (frames) => { + const source = upsert(frames, "source").shard; + source.edges.push({ + kind: "calls", + from: source.nodes[0]!.id, + to: "missing", + }); + refreshDigests(frames); + }), + ], + [ + "an edge with an absent source endpoint", + mutate(valid, (frames) => { + const source = upsert(frames, "source").shard; + source.edges.push({ + kind: "calls", + from: "missing", + to: source.nodes[0]!.id, + }); + refreshDigests(frames); + }), + ], + [ + "a duplicate shard delta", + mutate(valid, (frames) => { + frames.splice(3, 0, structuredClone(frames[2]!)); + }), + ], + [ + "an unexpected middle frame", + mutate(valid, (frames) => { + frames.splice(2, 0, structuredClone(frames[0]!)); + }), + ], + ]; +} + +function cloneSnapshot( + snapshot: IBulkGraphSession.ISnapshot, +): IBulkGraphSession.ISnapshot { + const { + sources: _sources, + ...plain + } = snapshot; + return { + ...structuredClone(plain), + sources: new Map( + [...snapshot.sources].map(([file, value]) => [file, { ...value }]), + ), + }; +} + +function invalidProtocolSnapshots(): Array< + [string, string, (snapshot: IBulkGraphSession.ISnapshot) => void] +> { + return [ + [ + "an unknown committed protocol version", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.version = 2; + }, + ], + [ + "an empty committed generation", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.generation = ""; + }, + ], + [ + "an empty committed target set", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.targets = []; + }, + ], + [ + "duplicate committed targets", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.targets.push(snapshot.protocol!.targets[0]!); + }, + ], + [ + "a malformed committed manifest digest", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.manifest = "bad"; + }, + ], + [ + "a malformed committed fact digest", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.factDigest = "bad"; + }, + ], + [ + "missing committed coverage", + "invalid protocol generation", + (snapshot) => { + snapshot.coverage = undefined; + snapshot.unresolved = []; + }, + ], + [ + "missing committed uncertainty", + "invalid protocol generation", + (snapshot) => { + snapshot.unresolved = undefined; + }, + ], + [ + "unresolved evidence absent from the source manifest", + "without binding that file to its source manifest", + (snapshot) => { + snapshot.unresolved![0]!.evidence.file = "src/missing.ts"; + }, + ], + [ + "a NUL-delimited committed source identity", + "source identity that is not normalized and absolute", + (snapshot) => { + snapshot.sources = new Map( + [...snapshot.sources].map(([file, value]) => [ + `${file}\0other`, + value, + ]), + ); + }, + ], + [ + "a fractional protocol generation sequence", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.sequence = 1.5; + }, + ], + [ + "a non-positive protocol generation sequence", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.sequence = 0; + }, + ], + [ + "a fractional protocol base sequence", + "invalid protocol generation", + (snapshot) => { + Object.assign(snapshot.protocol!, { + sequence: 2, + baseSequence: 1.5, + baseGeneration: "base", + }); + }, + ], + [ + "a non-positive protocol base sequence", + "invalid protocol generation", + (snapshot) => { + Object.assign(snapshot.protocol!, { + sequence: 2, + baseSequence: 0, + baseGeneration: "base", + }); + }, + ], + [ + "a protocol base sequence not older than its generation", + "invalid protocol generation", + (snapshot) => { + Object.assign(snapshot.protocol!, { + sequence: 2, + baseSequence: 2, + baseGeneration: "base", + }); + }, + ], + [ + "a non-string protocol base generation", + "invalid protocol generation", + (snapshot) => { + Object.assign(snapshot.protocol!, { + sequence: 2, + baseSequence: 1, + baseGeneration: 1, + }); + }, + ], + [ + "an empty protocol base generation", + "invalid protocol generation", + (snapshot) => { + Object.assign(snapshot.protocol!, { + sequence: 2, + baseSequence: 1, + baseGeneration: "", + }); + }, + ], + [ + "a NUL protocol base generation", + "invalid protocol generation", + (snapshot) => { + Object.assign(snapshot.protocol!, { + sequence: 2, + baseSequence: 1, + baseGeneration: "bad\0base", + }); + }, + ], + [ + "a protocol base token without its sequence", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.baseGeneration = "orphan"; + }, + ], + [ + "a non-string protocol generation", + "invalid protocol generation", + (snapshot) => { + record(snapshot.protocol!).generation = 1; + }, + ], + [ + "a NUL protocol generation", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.generation = "bad\0generation"; + }, + ], + [ + "a non-string protocol target", + "invalid protocol generation", + (snapshot) => { + record(snapshot.protocol!).targets = [1]; + }, + ], + [ + "an empty protocol target", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.targets[0] = ""; + }, + ], + [ + "a NUL protocol target", + "invalid protocol generation", + (snapshot) => { + snapshot.protocol!.targets[0] = "bad\0target"; + }, + ], + [ + "an empty committed shard key", + "invalid protocol shard manifest", + (snapshot) => { + snapshot.protocol!.shards[0]!.key = ""; + }, + ], + [ + "a NUL committed shard key", + "invalid protocol shard manifest", + (snapshot) => { + snapshot.protocol!.shards[0]!.key = "bad\0key"; + }, + ], + [ + "duplicate committed shard keys", + "invalid protocol shard manifest", + (snapshot) => { + snapshot.protocol!.shards[1]!.key = + snapshot.protocol!.shards[0]!.key; + }, + ], + [ + "a malformed committed shard digest", + "invalid protocol shard manifest", + (snapshot) => { + snapshot.protocol!.shards[0]!.digest = "bad"; + }, + ], + [ + "a mismatched committed fact digest", + "mismatched protocol fact digest", + (snapshot) => { + snapshot.protocol!.factDigest = digest("f"); + }, + ], + ]; +} + +async function rejectedWithoutMovement( + store: GraphSnapshotProtocol.Store, + frames: readonly GraphSnapshotProtocol.Frame[], + label: string, + signal?: AbortSignal, +): Promise { + const before = store.current; + await TestValidator.error(`${label} rejects`, () => + store.apply(frames, { signal }), + ); + TestValidator.predicate(`${label} retains the prior generation`, store.current === before); +} + +function record(value: object): Record { + return value as Record; +} diff --git a/tests/test-graph/src/features/test_language_registry_lists_advertised_targets.ts b/tests/test-graph/src/features/test_language_registry_lists_advertised_targets.ts index 02d85f70..504d49f4 100644 --- a/tests/test-graph/src/features/test_language_registry_lists_advertised_targets.ts +++ b/tests/test-graph/src/features/test_language_registry_lists_advertised_targets.ts @@ -1,8 +1,14 @@ import { TestValidator } from "@nestia/e2e"; -import { LANGUAGE_SPECS, languageOf } from "@samchon/graph"; +import { + LANGUAGE_SPECS, + allExtensions, + languageOf, + languagesOf, +} from "@samchon/graph"; import { GraphFixtures } from "../internal/GraphFixtures"; +/** Proves registry order, extension ownership, and advertised defaults agree. */ export const test_language_registry_lists_advertised_targets = () => { TestValidator.equals( "advertised language order", @@ -48,6 +54,15 @@ export const test_language_registry_lists_advertised_targets = () => { languageOf("include/interface.h"), "c", ); + TestValidator.equals( + "lowercase .h reaches both contextual owners", + languagesOf("include/interface.h"), + ["cpp", "c"], + ); + TestValidator.predicate( + "C++-only discovery includes shared .h inputs", + allExtensions(["cpp"]).has(".h"), + ); TestValidator.equals( "an unregistered uppercase suffix remains unknown after folded lookup", languageOf("README.MD"), diff --git a/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts b/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts index f2030591..800c3e39 100644 --- a/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts +++ b/tests/test-graph/src/features/test_lsp_client_closes_servers_that_break_the_shutdown_handshake.ts @@ -19,7 +19,18 @@ interface ILspClient { } interface ILspClientInternals { - pending: Map; + pending: Map< + number, + { + resolve(value: unknown): void; + reject(error: Error): void; + timer: NodeJS.Timeout | undefined; + signal?: AbortSignal; + abort?: () => void; + } + >; + handleMessage(message: unknown): void; + write(payload: unknown): void; process: { stdin: { destroy(error?: Error): void; @@ -77,6 +88,7 @@ type LspClientConstructor = new ( maxMessageBytes?: number, windowsVerbatimArguments?: boolean, requestObserver?: (event: LspRequestTrace) => void, + serverRequestHandler?: (method: string, params: unknown) => unknown, ) => ILspClient; /** `LspClient` is internal transport, reached through the shipped artifact. */ @@ -85,6 +97,20 @@ const importLib = (relative: string): Promise => pathToFileURL(path.join(GraphPaths.graphPackageRoot, "lib", relative)).href ) as Promise; +/** + * A language server that misbehaves during teardown leaves nothing behind in + * the graph, so no result-shaped assertion can notice it; the evidence is a + * process that outlives its session, or wall clock nobody can account for. + * + * The two servers below break the handshake in opposite directions, and each + * inline comment states its own case. What is worth saying once, here, is why + * both are needed: the correct response to one is escalation and to the other + * is refusing to escalate, so a client that handled only the first would still + * pass a suite that only asked about leaks. + * + * The case then continues into the rest of the client's process and transport + * surface. + */ export const test_lsp_client_closes_servers_that_break_the_shutdown_handshake = async () => { const { LspClient } = await importLib<{ @@ -158,6 +184,7 @@ export const test_lsp_client_closes_servers_that_break_the_shutdown_handshake = await assertOversizedHeadersTerminateTransport(LspClient); await assertRequestTracing(LspClient); await assertRequestTraceFormatting(); + await assertServerRequestFailureAndBareResponse(LspClient); // An already-cancelled request never enters the wire or waits for the // otherwise-unlimited default deadline. The client still owns its child and @@ -180,6 +207,82 @@ export const test_lsp_client_closes_servers_that_break_the_shutdown_handshake = await cancelled.close(); }; +const assertServerRequestFailureAndBareResponse = async ( + LspClient: LspClientConstructor, +): Promise => { + const client = new LspClient( + process.execPath, + [GraphPaths.fakeLspServer], + undefined, + undefined, + undefined, + undefined, + undefined, + (method) => { + if (method === "fixture/failure") { + throw "fixture server-request failure"; + } + return undefined; + }, + ); + const internals = client as unknown as ILspClientInternals; + const written: unknown[] = []; + const originalWrite = internals.write.bind(client); + try { + internals.write = (payload) => void written.push(payload); + internals.handleMessage({ + jsonrpc: "2.0", + id: 7001, + method: "fixture/failure", + params: {}, + }); + await new Promise((resolve) => setImmediate(resolve)); + TestValidator.equals( + "a rejected server-request handler returns a normalized JSON-RPC error", + written, + [ + { + jsonrpc: "2.0", + id: 7001, + error: { + code: -32603, + message: "fixture server-request failure", + }, + }, + ], + ); + + internals.handleMessage({ + jsonrpc: "2.0", + id: 7002, + method: "fixture/undefined", + params: {}, + }); + await new Promise((resolve) => setImmediate(resolve)); + TestValidator.equals( + "an undefined server-request handler result remains valid JSON-RPC", + written[1], + { jsonrpc: "2.0", id: 7002, result: null }, + ); + + let bare: Error & { code?: number } | undefined; + internals.pending.set(7003, { + resolve: () => undefined, + reject: (error) => void (bare = error as Error & { code?: number }), + timer: undefined, + }); + internals.handleMessage({ jsonrpc: "2.0", id: 7003, error: {} }); + TestValidator.equals( + "a bare LSP response error receives the protocol defaults", + [bare?.name, bare?.code, bare?.message], + ["LspResponseError", -32603, "LSP request failed."], + ); + } finally { + internals.write = originalWrite; + await client.close(); + } +}; + const assertRequestTracing = async ( LspClient: LspClientConstructor, ): Promise => { diff --git a/tests/test-graph/src/features/test_mcp_resident_close_handler_settles_once.ts b/tests/test-graph/src/features/test_mcp_resident_close_handler_settles_once.ts index dc8cffae..94222d82 100644 --- a/tests/test-graph/src/features/test_mcp_resident_close_handler_settles_once.ts +++ b/tests/test-graph/src/features/test_mcp_resident_close_handler_settles_once.ts @@ -3,7 +3,16 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { GraphPaths } from "../internal/GraphPaths"; +import { createCompositeResidentClose } from "../../../../packages/graph/src/mcp/createCompositeResidentClose"; +/** + * Shutdown arrives twice — the transport closing and stdin ending are separate + * events — and both reach the same handler. Closing twice would either kill a + * resident mid-teardown or double-report the same failure, and neither shows up + * in a single-path test. This pins one shared shutdown promise, one close, one + * contained report, and the composite that now fronts several resident planes + * closing each of them in order while retaining the first failure. + */ export const test_mcp_resident_close_handler_settles_once = async () => { const module = (await import( pathToFileURL( @@ -47,4 +56,44 @@ export const test_mcp_resident_close_handler_settles_once = async () => { reports, [failure], ); + + const calls: string[] = []; + await createCompositeResidentClose([ + undefined, + { close: async () => void calls.push("code") }, + { close: async () => void calls.push("topology") }, + ]).close(); + TestValidator.equals( + "the composite closes every opened resident plane in order", + calls, + ["code", "topology"], + ); + + const firstFailure = new Error("code close failed"); + let topologyClosed = false; + await TestValidator.error( + "the composite retains the first failure while closing later planes", + () => + createCompositeResidentClose([ + { close: async () => Promise.reject(firstFailure) }, + { + close: async () => { + topologyClosed = true; + throw "topology close failed"; + }, + }, + ]).close(), + ); + TestValidator.equals( + "a first close failure does not skip the topology plane", + topologyClosed, + true, + ); + await TestValidator.error( + "a non-Error close failure is normalized", + () => + createCompositeResidentClose([ + { close: async () => Promise.reject("string close failure") }, + ]).close(), + ); }; diff --git a/tests/test-graph/src/features/test_mcp_results_preserve_graph_coverage_and_uncertainty.ts b/tests/test-graph/src/features/test_mcp_results_preserve_graph_coverage_and_uncertainty.ts new file mode 100644 index 00000000..e45691e7 --- /dev/null +++ b/tests/test-graph/src/features/test_mcp_results_preserve_graph_coverage_and_uncertainty.ts @@ -0,0 +1,197 @@ +import { TestValidator } from "@nestia/e2e"; +import { + GRAPH_EDGE_KINDS, + ISamchonGraphDump, + SamchonGraphApplication, + SamchonGraphMemory, +} from "@samchon/graph"; +import path from "node:path"; + +/** Provenance, completeness and uncertainty survive dump, memory and MCP. */ +export const test_mcp_results_preserve_graph_coverage_and_uncertainty = + async () => { + const dump = fixture(); + const memory = SamchonGraphMemory.from(dump); + TestValidator.equals( + "memory retains the exact public trust planes", + [ + memory.provenance[0]?.universe, + memory.coverage.length, + memory.unresolved[0]?.reason, + ], + ["a".repeat(64), GRAPH_EDGE_KINDS.length, "dynamic"], + ); + + const application = new SamchonGraphApplication(memory); + const lookup = await application.inspect_code_graph({ + question: "where is run", + draft: { reason: "named symbol", type: "lookup" }, + review: "lookup is exact", + request: { type: "lookup", query: "run" }, + }); + TestValidator.equals( + "lookup reports every family that can affect ranking", + [ + lookup.provenance?.[0]?.provider, + lookup.coverage?.schemaVersion, + lookup.coverage?.families, + lookup.coverage?.rows.length, + lookup.unresolved, + ], + [ + "fixture-compiler", + 1, + GRAPH_EDGE_KINDS.filter( + (family) => + family === "exports" || + !["contains", "exports", "imports"].includes(family), + ), + GRAPH_EDGE_KINDS.length - 2, + { + count: 2, + reasons: [ + { reason: "dynamic", count: 1 }, + { reason: "reflection", count: 1 }, + ], + examples: dump.unresolved, + }, + ], + ); + + const entrypoints = await application.inspect_code_graph({ + question: "where does run begin", + draft: { reason: "first handles", type: "entrypoints" }, + review: "entrypoints is exact", + request: { type: "entrypoints", query: "run" }, + }); + TestValidator.equals( + "entrypoints includes the lookup and neighborhood trust families", + [ + entrypoints.coverage?.families, + entrypoints.unresolved?.count, + ], + [lookup.coverage?.families, 2], + ); + + const overview = await application.inspect_code_graph({ + question: "what are the architectural hotspots", + draft: { reason: "dependency ranking", type: "overview" }, + review: "overview is exact", + request: { type: "overview", aspect: "hotspots" }, + }); + TestValidator.equals( + "overview reports every family counted or used for ranking", + [ + overview.coverage?.families, + overview.unresolved?.count, + ], + [GRAPH_EDGE_KINDS, 2], + ); + + const trace = await application.inspect_code_graph({ + question: "what does run call", + draft: { reason: "dependency flow", type: "trace" }, + review: "trace is exact", + request: { type: "trace", from: "run" }, + }); + TestValidator.equals( + "trace carries all-family coverage and bounded structured uncertainty", + [ + trace.coverage?.families.length, + trace.unresolved?.count, + trace.unresolved?.reasons, + trace.unresolved?.examples[0]?.candidates, + trace.unresolved?.examples[1]?.candidates, + ], + [ + GRAPH_EDGE_KINDS.length, + 2, + [ + { reason: "dynamic", count: 1 }, + { reason: "reflection", count: 1 }, + ], + ["src/main.ts#target:function"], + undefined, + ], + ); + + const escaped = await application.inspect_code_graph({ + question: "read a body", + draft: { reason: "outside graph", type: "escape" }, + review: "escape", + request: { type: "escape", reason: "body text" }, + }); + TestValidator.equals( + "escape does not load or invent a graph trust envelope", + [escaped.provenance, escaped.coverage, escaped.unresolved], + [undefined, undefined, undefined], + ); + }; + +function fixture(): ISamchonGraphDump { + const universe = "a".repeat(64); + return { + project: path.resolve("fixture"), + languages: ["typescript"], + indexer: "lsp", + provenance: [ + { + provider: "fixture-compiler", + languages: ["typescript"], + authority: "compiler", + facts: ["calls"], + capabilities: ["universe"], + producer: { + tool: "fixture-exporter", + version: "1.0.0", + compiler: "fixture-1", + schemaVersion: 7, + protocolVersion: 1, + }, + universe, + manifest: "b".repeat(64), + content: "c".repeat(64), + }, + ], + coverage: GRAPH_EDGE_KINDS.map((family) => ({ + provider: "fixture-compiler", + language: "typescript", + target: "app", + family, + state: family === "calls" ? "partial" : "unsupported", + })), + unresolved: [ + { + provider: "fixture-compiler", + language: "typescript", + target: "app", + universe, + family: "calls", + evidence: { file: "src/main.ts", startLine: 1, startCol: 1 }, + reason: "dynamic", + candidates: ["src/main.ts#target:function"], + }, + { + provider: "fixture-compiler", + language: "typescript", + target: "app", + universe, + family: "calls", + evidence: { file: "src/main.ts", startLine: 2, startCol: 1 }, + reason: "reflection", + }, + ], + nodes: [ + { + id: "src/main.ts#run:function", + kind: "function", + language: "typescript", + name: "run", + file: "src/main.ts", + external: false, + evidence: { startLine: 1, startCol: 1 }, + }, + ], + edges: [], + }; +} diff --git a/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts b/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts index 3a2623fc..5384e9ff 100644 --- a/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts +++ b/tests/test-graph/src/features/test_mcp_server_exposes_inspect_code_graph.ts @@ -59,12 +59,12 @@ const overview = async (args: string[]) => { "the result arrives as structured content", payload !== undefined, ); - // `audit` serializes first, so what was checked precedes any fact a reader - // might second-guess; `next` says where the result leaves the question. + // `audit` serializes first, then the structured completeness evidence, + // before `next` says where the result leaves the question. TestValidator.equals( "audit leads, then where it leaves the question, then the facts", Object.keys(payload), - ["audit", "next", "result"], + ["audit", "coverage", "unresolved", "next", "result"], ); return payload; } finally { @@ -72,6 +72,16 @@ const overview = async (args: string[]) => { } }; +/** + * Everything else about the graph is tested through the TypeScript API, which + * cannot see the one boundary an agent actually uses: a spawned process, one + * registered tool, and a structured result arriving over stdio. + * + * It runs twice because there are two ways to reach that boundary, and only + * one of them indexes anything. The `--graph-file` server is held to the same + * node count as the lane that indexed the project, so a graph file served + * stale or in part is a failure rather than a smaller answer. + */ export const test_mcp_server_exposes_inspect_code_graph = async () => { const root = GraphFixtures.createOrderFixture(); const parsed = await overview(["--mode", "static", "--cwd", root]); diff --git a/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts new file mode 100644 index 00000000..0aa7021c --- /dev/null +++ b/tests/test-graph/src/features/test_mcp_topology_fences_file_joins_by_code_generation.ts @@ -0,0 +1,433 @@ +import { TestValidator } from "@nestia/e2e"; +import { + ISamchonRepositoryContextDump, + RepositoryContextProtocol, + SamchonGraphApplication, + SamchonGraphMemory, + SamchonRepositoryContextMemory, + repositoryContextFacts, +} from "@samchon/graph"; +import fs from "node:fs"; + +import { GraphFixtures } from "../internal/GraphFixtures"; + +const { repositoryContextCoverage, repositoryContextId } = + repositoryContextFacts; + +/** + * A `joins-file` edge is the one place the two planes touch, and it is only + * true of one pair of generations. If the code generation moves while topology + * is loading, the join still looks well-formed — both endpoints exist — so + * nothing in either plane's own validation can reject it. This pins the fence + * that can: joins are admitted only when the code input generation is stable + * across the topology load, and are otherwise withheld with an explicit + * `unavailable` reason rather than returned as ordinary facts. + */ +export const test_mcp_topology_fences_file_joins_by_code_generation = + async () => { + const fixture = GraphFixtures.createContractFixture(); + try { + const input = "a".repeat(64); + const graph = SamchonGraphMemory.from({ + ...fixture.dump, + generation: { input }, + }); + const topology = new SamchonRepositoryContextMemory( + topologyDump(fixture.dump.project), + ); + const application = new SamchonGraphApplication(graph, () => topology); + const compatible = await application.inspect_code_graph({ + question: "show repository packages and their source files", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is the typed repository plane", + request: { + type: "topology", + query: "source", + relations: ["joins-file"], + limit: 10, + }, + }); + TestValidator.equals( + "a stable code generation admits only joins to indexed code files", + [ + compatible.result.type, + compatible.result.type === "topology" + ? compatible.result.join.state + : undefined, + compatible.result.type === "topology" + ? compatible.result.edges.map((edge) => edge.to) + : [], + compatible.result.type === "topology" + ? compatible.result.coverage.map((row) => row.family) + : [], + ], + [ + "topology", + "compatible", + ["src/contract.ts"], + ["joins-file"], + ], + ); + + const bounded = await application.inspect_code_graph({ + question: "show one repository file join", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is the typed repository plane", + request: { + type: "topology", + relations: ["joins-file"], + joinLimit: 1, + }, + }); + TestValidator.equals( + "incompatible file identities are removed before the join bound is evaluated", + [ + bounded.result.type === "topology" + ? bounded.result.edges.filter( + (edge) => edge.kind === "joins-file", + ).length + : -1, + bounded.result.type === "topology" + ? bounded.result.truncated + : false, + ], + [1, false], + ); + + const endpointBounded = await application.inspect_code_graph({ + question: "show the source relation", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is the typed repository plane", + request: { + type: "topology", + query: "source", + relations: ["contains"], + limit: 1, + }, + }); + TestValidator.equals( + "dropping a relation endpoint at the node bound reports truncation", + endpointBounded.result.type === "topology" + ? [ + endpointBounded.result.nodes.length, + endpointBounded.result.nodes[0]?.name, + endpointBounded.result.edges.length, + endpointBounded.result.truncated, + ] + : [], + [1, "source", 0, true], + ); + + const legacy = SamchonGraphMemory.from({ + ...fixture.dump, + generation: undefined, + }); + const unavailable = await new SamchonGraphApplication( + legacy, + () => topology, + ).inspect_code_graph({ + question: "show repository topology", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is correct", + request: { type: "topology", limit: 1 }, + }); + TestValidator.equals( + "a legacy code dump cannot receive topology file joins", + [ + unavailable.result.type === "topology" + ? unavailable.result.join.state + : undefined, + unavailable.result.type === "topology" + ? unavailable.result.edges.some( + (edge) => edge.kind === "joins-file", + ) + : true, + unavailable.result.type === "topology" + ? unavailable.result.truncated + : false, + // The reason, not only the state. A graph file served without + // revalidation reaches this branch on every call, so it is the + // sentence most callers actually read, and it must not tell them a + // generation moved when one was withheld on purpose. + unavailable.result.type === "topology" + ? unavailable.result.join.reason + : undefined, + ], + [ + "unavailable", + false, + true, + "This code graph carries no input generation to fence against: a graph file served without revalidation withholds one, and dumps written before cross-plane fencing never had one.", + ], + ); + + // Two planes describing different repositories reach the same + // `unavailable` state as a moved generation, which is why the reason has + // to distinguish them: these two never had a generation in common, and + // reporting one as having moved would send a reader looking for a race + // that did not happen. + const foreign = await new SamchonGraphApplication(graph, () => + new SamchonRepositoryContextMemory( + topologyDump(`${fixture.dump.project}-elsewhere`), + ), + ).inspect_code_graph({ + question: "show repository topology", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is correct", + request: { type: "topology", limit: 1 }, + }); + TestValidator.equals( + "a topology model for another project names that as the reason", + foreign.result.type === "topology" + ? [ + foreign.result.join.state, + foreign.result.join.reason, + foreign.result.edges.some((edge) => edge.kind === "joins-file"), + ] + : [], + [ + "unavailable", + "The code graph and the repository-context model describe different projects, so their file identities are not comparable.", + false, + ], + ); + + let loads = 0; + const moved = SamchonGraphMemory.from({ + ...fixture.dump, + generation: { input: "moved".padEnd(64, "0") }, + }); + const stale = await new SamchonGraphApplication( + () => (loads++ === 0 ? graph : moved), + () => topology, + ).inspect_code_graph({ + question: "show repository topology", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is correct", + request: { type: "topology" }, + }); + TestValidator.equals( + "a code generation that moves across the topology load refuses stale file joins", + [ + stale.result.type === "topology" + ? stale.result.join.state + : undefined, + stale.result.type === "topology" + ? stale.result.edges.some((edge) => edge.kind === "joins-file") + : true, + stale.result.type === "topology" + ? stale.result.join.reason + : undefined, + // A model that holds nodes but matched none is a restatement, not an + // escape: the same call without `query` lists what it holds. Only a + // model holding nothing at all sends the caller elsewhere. + stale.next.action, + ], + [ + "unavailable", + false, + "The code generation moved while topology was loading.", + "answer", + ], + ); + + const misspelled = await new SamchonGraphApplication(graph, () => + topology, + ).inspect_code_graph({ + question: "show the lib package", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is correct", + request: { type: "topology", query: "sorce" }, + }); + TestValidator.equals( + "an exact-match miss against a populated model is a restatement", + [ + misspelled.next.action, + misspelled.result.type === "topology" + ? misspelled.result.nodes.length + : -1, + ], + ["clarify", 0], + ); + + const emptyTopology = new SamchonRepositoryContextMemory({ + ...topology.dump, + provenance: [], + coverage: topology.dump.coverage.map((row) => ({ + ...row, + target: "unavailable", + state: "unsupported", + })), + nodes: [], + edges: [], + files: [], + }); + const providerUnavailable = await new SamchonGraphApplication( + graph, + () => emptyTopology, + ).inspect_code_graph({ + question: "show repository topology", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is correct", + request: { type: "topology" }, + }); + TestValidator.equals( + "an unavailable provider generation cannot claim join compatibility", + [ + providerUnavailable.result.type === "topology" + ? providerUnavailable.result.join + : undefined, + providerUnavailable.next.reason, + // The action, not only the sentence beside it. `answer` tells the + // caller to stop because the result carries what they asked for, and + // a plane holding no nodes at all carries nothing — this is the one + // empty case where the answer really is elsewhere. + providerUnavailable.next.action, + ], + [ + { + state: "unavailable", + topologyInputGeneration: emptyTopology.dump.inputGeneration, + codeInputGeneration: input, + reason: + "No repository-context provider produced a compatible current generation.", + }, + "No repository-context provider published any topology node for this project, so the repository plane has nothing to answer from.", + "outside", + ], + ); + + await TestValidator.error( + "the topology branch fails explicitly without a repository source", + () => + new SamchonGraphApplication(graph).inspect_code_graph({ + question: "show repository topology", + draft: { reason: "repository orientation", type: "topology" }, + review: "topology is correct", + request: { type: "topology" }, + }), + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }; + +function topologyDump(project: string): ISamchonRepositoryContextDump { + const workspace = repositoryContextId("fixture", "workspace", "."); + const sourceHelper = repositoryContextId( + "fixture", + "source-root", + "source-helper", + ); + const upperSource = repositoryContextId( + "fixture", + "source-root", + "Source", + ); + const source = repositoryContextId("fixture", "source-root", "src"); + const nodes: ISamchonRepositoryContextDump.INode[] = [ + { + id: workspace, + authority: "declared", + kind: "workspace", + name: "fixture", + ecosystem: "fixture", + coordinate: ".", + configuration: "default", + external: false, + }, + { + id: sourceHelper, + authority: "declared", + kind: "source-root", + name: "source-helper", + ecosystem: "fixture", + coordinate: "source-helper", + configuration: "default", + external: false, + }, + { + id: upperSource, + authority: "declared", + kind: "source-root", + name: "Source", + ecosystem: "fixture", + coordinate: "Source", + configuration: "default", + external: false, + }, + { + id: source, + authority: "declared", + kind: "source-root", + name: "source", + ecosystem: "fixture", + coordinate: "src", + configuration: "default", + external: false, + }, + ]; + const edges: ISamchonRepositoryContextDump.IEdge[] = [ + { + authority: "declared", + kind: "contains", + from: workspace, + to: source, + }, + { + authority: "declared", + kind: "joins-file", + from: source, + to: "src/contract.ts", + }, + { + authority: "declared", + kind: "joins-file", + from: source, + to: "src/not-indexed.ts", + }, + ]; + const coverage = repositoryContextCoverage( + "fixture-context", + "fixture", + "workspace", + ["contains", "joins-file"], + ); + const contentDigest = RepositoryContextProtocol.digest({ + nodes, + edges, + coverage, + }); + return { + project, + schemaVersion: 1, + inputGeneration: "b".repeat(64), + generation: { + sequence: 1, + token: "c".repeat(64), + shards: [{ key: "fixture", digest: "d".repeat(64) }], + contentDigest, + }, + provenance: [ + { + provider: "fixture-context", + ecosystem: "fixture", + authority: "declared", + tool: "fixture", + toolVersion: "1", + schemaVersion: 1, + protocolVersion: 1, + universe: "e".repeat(64), + manifest: "f".repeat(64), + content: contentDigest, + capabilities: ["fixture"], + }, + ], + coverage, + nodes, + edges, + files: ["src/contract.ts", "src/not-indexed.ts"], + sources: [{ file: "fixture.json", digest: "a".repeat(64) }], + warnings: [], + }; +} diff --git a/tests/test-graph/src/features/test_provider_commands_and_inputs_respect_project_boundaries.ts b/tests/test-graph/src/features/test_provider_commands_and_inputs_respect_project_boundaries.ts index b48f891c..8ccc23ba 100644 --- a/tests/test-graph/src/features/test_provider_commands_and_inputs_respect_project_boundaries.ts +++ b/tests/test-graph/src/features/test_provider_commands_and_inputs_respect_project_boundaries.ts @@ -321,34 +321,57 @@ export const test_provider_commands_and_inputs_respect_project_boundaries = expectedCommand(goCommand, ["--project", path.resolve(root)]), ); fs.rmSync(goCommand, { force: true }); - const bundledGo = goGraphProvider.resolve(root, process.env); - TestValidator.predicate( - "the packaged Go source sidecar runs through the available toolchain", - bundledGo !== undefined && - bundledGo.args.includes("-C") && - bundledGo.args.slice(-4).join(" ") === - `run . --project ${path.resolve(root)}`, - ); - if (bundledGo === undefined) { - throw new Error("the packaged Go source sidecar was not resolved"); - } - const sourceFlag = bundledGo.args.indexOf("-C"); - const bundledSource = bundledGo.args[sourceFlag + 1]; - if (sourceFlag < 0 || bundledSource === undefined) { - throw new Error("the packaged Go source directory was not resolved"); + + const hostGo = goGraphProvider.resolve(root, process.env); + if (hostGo !== undefined) { + TestValidator.predicate( + "an available host Go runs the packaged source sidecar", + hostGo.args.includes("-C") && + hostGo.args.slice(-4).join(" ") === + `run . --project ${path.resolve(root)}`, + ); } + + const bundledSource = path.join( + GraphPaths.graphPackageRoot, + "sidecars", + "go", + ); + const sourceGo = platformExecutable(privateBin, "go"); + writeExecutable(sourceGo); + const sourceRunner = goGraphProvider.resolve(root, { + ...emptyPath, + SAMCHON_GRAPH_GO_TOOLCHAIN: sourceGo, + }); + TestValidator.equals( + "a deterministically present Go runs the packaged source sidecar", + sourceRunner, + spawnableCommand.append(expectedCommand(sourceGo), [ + "-C", + bundledSource, + "run", + ".", + "--project", + path.resolve(root), + ]), + ); + const bundledManifest = path.join(bundledSource, "go.mod"); const hiddenManifest = `${bundledManifest}.test-hidden`; fs.renameSync(bundledManifest, hiddenManifest); try { TestValidator.equals( "a malformed package without its Go source sidecar declines cleanly", - goGraphProvider.resolve(root, process.env), + goGraphProvider.resolve(root, { + ...emptyPath, + SAMCHON_GRAPH_GO_TOOLCHAIN: sourceGo, + }), undefined, ); } finally { fs.renameSync(hiddenManifest, bundledManifest); } + fs.rmSync(sourceGo, { force: true }); TestValidator.equals( "the packaged Go source sidecar declines without a Go toolchain", goGraphProvider.resolve(root, emptyPath), diff --git a/tests/test-graph/src/features/test_provider_registry_selects_one_owner_per_language.ts b/tests/test-graph/src/features/test_provider_registry_selects_one_owner_per_language.ts index 4c3e44a5..f4c2d572 100644 --- a/tests/test-graph/src/features/test_provider_registry_selects_one_owner_per_language.ts +++ b/tests/test-graph/src/features/test_provider_registry_selects_one_owner_per_language.ts @@ -36,9 +36,73 @@ export const test_provider_registry_selects_one_owner_per_language = await assertSnapshotContract(); await assertCrossProviderCollisions(); await assertDigestsAndProvenance(); + await assertRuntimeFallback(); await assertStrictBuildCanonicalizesMultiProviderState(); }; +async function assertRuntimeFallback(): Promise { + const root = GraphPaths.createTempDirectory("samchon-graph-provider-fallback-"); + fs.writeFileSync(path.join(root, "index.ts"), "export const value = 1;\n"); + let primaryCloses = 0; + let fallbackCloses = 0; + const fallback = ProviderFixtures.provider({ + name: "fixture-semantic-fallback", + open: (props) => + ProviderFixtures.session({ + root: props.root, + languages: [...props.languages], + snapshots: [ + ProviderFixtures.snapshot({ + root: props.root, + languages: [...props.languages], + provider: "fixture-semantic-fallback", + authority: "compiler", + }), + ], + onClose: () => { + fallbackCloses += 1; + }, + }), + }); + const primary: IGraphProvider = { + ...ProviderFixtures.provider({ + name: "fixture-failing-primary", + open: (props) => + ProviderFixtures.session({ + root: props.root, + languages: [...props.languages], + onRefresh: () => { + throw new Error("primary fixture exploded"); + }, + onClose: () => { + primaryCloses += 1; + }, + }), + }), + fallbacks: [fallback], + }; + + const result = await buildLspGraph( + { cwd: root, languages: ["typescript"] }, + { providers: [primary] }, + ); + TestValidator.equals( + "a failed strict route closes and steps down to its strict fallback", + [ + result.dump.provenance?.map((row) => row.provider), + result.dump.warnings.some( + (warning) => + warning.includes("fixture-failing-primary") && + warning.includes("fixture-semantic-fallback") && + warning.includes("primary fixture exploded"), + ), + primaryCloses, + fallbackCloses, + ], + [["fixture-semantic-fallback"], true, 1, 1], + ); +} + async function assertStrictBuildCanonicalizesMultiProviderState(): Promise { const root = GraphPaths.createTempDirectory("samchon-graph-provider-order-"); fs.writeFileSync(path.join(root, "index.ts"), "export const value = 1;\n"); @@ -87,7 +151,6 @@ async function assertSelection(): Promise { Object.fromEntries( GRAPH_PROVIDERS.filter((provider) => [ - "scip-clang", "scip-java", "scip-dotnet", "scip-python", @@ -98,7 +161,6 @@ async function assertSelection(): Promise { ).map((provider) => [provider.name, provider.facts]), ), { - "scip-clang": [], "scip-java": ["contains", "references"], "scip-dotnet": [], "scip-python": ["references"], @@ -254,6 +316,51 @@ async function assertSelection(): Promise { prepared, ], [1, 1]); + const compatibleFallback = ProviderFixtures.provider({ + name: "fake-ts-fallback", + }); + const primaryWithFallback: IGraphProvider = { + ...ProviderFixtures.provider({ name: "fake-ts-primary" }), + fallbacks: [compatibleFallback], + }; + const routed = selectGraphProviders( + "/root", + ["typescript"], + {}, + {}, + [primaryWithFallback], + ); + TestValidator.equals( + "a resolved primary retains its already-resolved fallback route", + [ + routed.candidates[0]?.provider.name, + routed.candidates[0]?.fallbacks.map((route) => route.provider.name), + ], + ["fake-ts-primary", ["fake-ts-fallback"]], + ); + + const missingPrimary = selectGraphProviders( + "/root", + ["typescript"], + {}, + {}, + [ + { + ...primaryWithFallback, + resolve: () => undefined, + }, + ], + ); + TestValidator.equals( + "a missing primary promotes the compatible fallback to the selected route", + [ + missingPrimary.candidates[0]?.provider.name, + missingPrimary.candidates[0]?.fallbacks, + missingPrimary.warnings.length, + ], + ["fake-ts-fallback", [], 1], + ); + // --- registry defects are static, not machine-dependent ----------------- TestValidator.error("two providers cannot own one language", () => selectGraphProviders( @@ -324,6 +431,44 @@ async function assertSelection(): Promise { ], ), ); + TestValidator.error("a fallback cannot own a different atomic language set", () => + selectGraphProviders( + "/root", + ["typescript"], + {}, + {}, + [ + { + ...primaryWithFallback, + fallbacks: [ + ProviderFixtures.provider({ + name: "wrong-language-fallback", + languages: ["go"], + }), + ], + }, + ], + ), + ); + TestValidator.error("a fallback cannot introduce another fallback tier", () => + selectGraphProviders( + "/root", + ["typescript"], + {}, + {}, + [ + { + ...primaryWithFallback, + fallbacks: [ + { + ...compatibleFallback, + fallbacks: [ProviderFixtures.provider({ name: "third-tier" })], + }, + ], + }, + ], + ), + ); // The shipped registry must satisfy its own rule. TestValidator.equals( diff --git a/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts b/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts new file mode 100644 index 00000000..36bfe81e --- /dev/null +++ b/tests/test-graph/src/features/test_provider_support_manifest_matches_registry_and_evidence.ts @@ -0,0 +1,351 @@ +import { TestValidator } from "@nestia/e2e"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +import { GraphPaths } from "../internal/GraphPaths"; + +/** + * Provider claims are generated public data, so registry, evidence, platform, + * command-resolution, and README projections must remain one closed contract. + * + * 1. Validate the canonical manifest and both README line-ending forms. + * 2. Mutate each governed dimension independently through temporary manifests. + * 3. Require every drift to fail with the provider-specific reason. + */ +export const test_provider_support_manifest_matches_registry_and_evidence = + () => { + const root = GraphPaths.createTempDirectory( + "samchon-graph-provider-support-", + ); + const canonical = path.join( + GraphPaths.repositoryRoot, + "docs", + "provider-support.json", + ); + try { + TestValidator.equals( + "the canonical support manifest matches registry, experiments and benchmark evidence", + validate(canonical, root), + { status: 0, stderr: "" }, + ); + + const sourceReadme = fs.readFileSync( + path.join(GraphPaths.repositoryRoot, "README.md"), + "utf8", + ); + const lfReadme = path.join(root, "README-lf.md"); + fs.writeFileSync(lfReadme, sourceReadme.replace(/\r\n/g, "\n")); + TestValidator.equals( + "the generated support block preserves an LF checkout", + checkReadme(canonical, lfReadme, root), + { status: 0, stderr: "" }, + ); + + const crlfReadme = path.join(root, "README-crlf.md"); + fs.writeFileSync( + crlfReadme, + sourceReadme.replace(/\r?\n/g, "\r\n"), + ); + TestValidator.equals( + "the generated support block preserves a CRLF checkout", + checkReadme(canonical, crlfReadme, root), + { status: 0, stderr: "" }, + ); + + const parsed = JSON.parse(fs.readFileSync(canonical, "utf8")) as { + providers: Array< + Record & { + commands?: unknown; + installSources?: unknown; + platforms?: unknown; + projectCommandSources?: unknown; + } + >; + }; + const missing = structuredClone(parsed); + missing.providers.shift(); + const missingFile = path.join(root, "missing-provider.json"); + fs.writeFileSync(missingFile, JSON.stringify(missing)); + TestValidator.predicate( + "an undocumented registered provider fails closed", + failsValidation( + validate(missingFile, root), + "undocumented registered provider ttscgraph", + ), + ); + + const absent = structuredClone(parsed); + absent.providers.push({ + ...absent.providers[0], + provider: "absent-provider", + languages: ["absent-language"], + }); + const absentFile = path.join(root, "absent-provider.json"); + fs.writeFileSync(absentFile, JSON.stringify(absent)); + TestValidator.predicate( + "a documented absent provider fails closed", + failsValidation( + validate(absentFile, root), + "documented absent provider absent-provider", + ), + ); + + const misspelledPlatform = structuredClone(parsed); + misspelledPlatform.providers[0]!.platforms = ["linxu"]; + const misspelledPlatformFile = path.join( + root, + "misspelled-platform.json", + ); + fs.writeFileSync( + misspelledPlatformFile, + JSON.stringify(misspelledPlatform), + ); + TestValidator.predicate( + "an unknown platform fails closed", + failsValidation( + validate(misspelledPlatformFile, root), + "ttscgraph names unknown platform linxu", + ), + ); + + const duplicatePlatform = structuredClone(parsed); + duplicatePlatform.providers[0]!.platforms = ["linux", "linux"]; + const duplicatePlatformFile = path.join( + root, + "duplicate-platform.json", + ); + fs.writeFileSync( + duplicatePlatformFile, + JSON.stringify(duplicatePlatform), + ); + TestValidator.predicate( + "a duplicate platform fails closed", + failsValidation( + validate(duplicatePlatformFile, root), + "ttscgraph platform rows must be unique", + ), + ); + + const duplicateCommand = structuredClone(parsed); + duplicateCommand.providers[0]!.commands = ["ttscgraph", "ttscgraph"]; + const duplicateCommandFile = path.join(root, "duplicate-command.json"); + fs.writeFileSync( + duplicateCommandFile, + JSON.stringify(duplicateCommand), + ); + TestValidator.predicate( + "a duplicate fixed command fails closed", + failsValidation( + validate(duplicateCommandFile, root), + "ttscgraph command rows must be unique", + ), + ); + + const incompleteProjectCommands = structuredClone(parsed); + const clang = incompleteProjectCommands.providers.find( + (provider) => provider.provider === "clangd-snapshot", + ); + if (clang === undefined) + throw new Error("the canonical manifest must contain clangd-snapshot"); + clang.projectCommandSources = ["compile_commands.json"]; + const incompleteProjectCommandsFile = path.join( + root, + "incomplete-project-command-sources.json", + ); + fs.writeFileSync( + incompleteProjectCommandsFile, + JSON.stringify(incompleteProjectCommands), + ); + TestValidator.predicate( + "an omitted project-owned command source fails closed", + failsValidation( + validate(incompleteProjectCommandsFile, root), + "clangd-snapshot project command sources differ from its resolver descriptor", + ), + ); + + const duplicateProjectCommands = structuredClone(parsed); + const duplicateClang = duplicateProjectCommands.providers.find( + (provider) => provider.provider === "clangd-snapshot", + ); + if (duplicateClang === undefined) + throw new Error("the canonical manifest must contain clangd-snapshot"); + duplicateClang.projectCommandSources = [ + "compile_commands.json", + "build/compile_commands.json", + "build/compile_commands.json", + ]; + const duplicateProjectCommandsFile = path.join( + root, + "duplicate-project-command-sources.json", + ); + fs.writeFileSync( + duplicateProjectCommandsFile, + JSON.stringify(duplicateProjectCommands), + ); + TestValidator.predicate( + "a duplicate project-owned command source fails closed", + failsValidation( + validate(duplicateProjectCommandsFile, root), + "clangd-snapshot project command source rows must be unique", + ), + ); + + const missingInstallSource = structuredClone(parsed); + missingInstallSource.providers[0]!.installSources = []; + const missingInstallSourceFile = path.join( + root, + "missing-install-source.json", + ); + fs.writeFileSync( + missingInstallSourceFile, + JSON.stringify(missingInstallSource), + ); + TestValidator.predicate( + "a missing install source fails closed", + failsValidation( + validate(missingInstallSourceFile, root), + "ttscgraph must name install sources", + ), + ); + + const duplicateInstallLabel = structuredClone(parsed); + duplicateInstallLabel.providers[0]!.installSources = [ + { label: "same", url: "https://example.com/one" }, + { label: "same", url: "https://example.com/two" }, + ]; + const duplicateInstallLabelFile = path.join( + root, + "duplicate-install-label.json", + ); + fs.writeFileSync( + duplicateInstallLabelFile, + JSON.stringify(duplicateInstallLabel), + ); + TestValidator.predicate( + "a duplicate install-source label fails closed", + failsValidation( + validate(duplicateInstallLabelFile, root), + "ttscgraph install-source label rows must be unique", + ), + ); + + const duplicateInstallUrl = structuredClone(parsed); + duplicateInstallUrl.providers[0]!.installSources = [ + { label: "one", url: "https://example.com/same" }, + { label: "two", url: "https://example.com/same" }, + ]; + const duplicateInstallUrlFile = path.join( + root, + "duplicate-install-url.json", + ); + fs.writeFileSync( + duplicateInstallUrlFile, + JSON.stringify(duplicateInstallUrl), + ); + TestValidator.predicate( + "a duplicate install-source URL fails closed", + failsValidation( + validate(duplicateInstallUrlFile, root), + "ttscgraph install-source URL rows must be unique", + ), + ); + + const unsafeInstallSource = structuredClone(parsed); + unsafeInstallSource.providers[0]!.installSources = [ + { label: "unsafe source", url: "http://example.com/package" }, + ]; + const unsafeInstallSourceFile = path.join( + root, + "unsafe-install-source.json", + ); + fs.writeFileSync( + unsafeInstallSourceFile, + JSON.stringify(unsafeInstallSource), + ); + TestValidator.predicate( + "a non-HTTPS install source fails closed", + failsValidation( + validate(unsafeInstallSourceFile, root), + "ttscgraph install source unsafe source must be an HTTPS URL", + ), + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }; + +function validate( + manifest: string, + coverageRoot: string, +): { + status: number | null; + stderr: string; +} { + const script = path.join( + GraphPaths.graphPackageRoot, + "build", + "provider-support.mjs", + ); + const result = spawnSync( + process.execPath, + [script, "--validate-only", `--manifest=${manifest}`], + { + cwd: GraphPaths.repositoryRoot, + encoding: "utf8", + env: { + ...process.env, + NODE_V8_COVERAGE: path.join(coverageRoot, "child-coverage"), + }, + windowsHide: true, + }, + ); + return { + status: result.status, + stderr: result.stderr, + }; +} + +function checkReadme( + manifest: string, + readme: string, + coverageRoot: string, +): { + status: number | null; + stderr: string; +} { + const script = path.join( + GraphPaths.graphPackageRoot, + "build", + "provider-support.mjs", + ); + const result = spawnSync( + process.execPath, + [script, "--check", `--manifest=${manifest}`, `--readme=${readme}`], + { + cwd: GraphPaths.repositoryRoot, + encoding: "utf8", + env: { + ...process.env, + NODE_V8_COVERAGE: path.join(coverageRoot, "child-coverage"), + }, + windowsHide: true, + }, + ); + return { + status: result.status, + stderr: result.stderr, + }; +} + +function failsValidation( + result: ReturnType, + diagnostic: string, +): boolean { + return ( + result.status !== null && + result.status !== 0 && + result.stderr.includes(diagnostic) + ); +} diff --git a/tests/test-graph/src/features/test_readme_names_a_published_ttsc_install_range.ts b/tests/test-graph/src/features/test_readme_names_a_published_ttsc_install_range.ts deleted file mode 100644 index 0f0bf526..00000000 --- a/tests/test-graph/src/features/test_readme_names_a_published_ttsc_install_range.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { TestValidator } from "@nestia/e2e"; -import fs from "node:fs"; -import path from "node:path"; - -import { GraphPaths } from "../internal/GraphPaths"; - -/** The public install command must name a version npm actually serves. */ -export const test_readme_names_a_published_ttsc_install_range = () => { - const install = "npm i -D ttsc@^0.20.1 typescript"; - const goInstall = - "go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7"; - for (const readme of [ - path.join(GraphPaths.repositoryRoot, "README.md"), - path.join(GraphPaths.graphPackageRoot, "README.md"), - ]) { - const text = fs.readFileSync(readme, "utf8"); - TestValidator.predicate( - `${path.relative(GraphPaths.repositoryRoot, readme)} names the published ttsc line`, - text.includes(install), - ); - TestValidator.predicate( - `${path.relative(GraphPaths.repositoryRoot, readme)} does not predict an unpublished ttsc line`, - text.includes("ttsc@^0.20.2") === false, - ); - TestValidator.predicate( - `${path.relative(GraphPaths.repositoryRoot, readme)} pins the Go navigation producer used by the bundled provider`, - text.includes(goInstall), - ); - } -}; diff --git a/tests/test-graph/src/features/test_readme_states_the_ttsc_shard_release_boundary.ts b/tests/test-graph/src/features/test_readme_states_the_ttsc_shard_release_boundary.ts new file mode 100644 index 00000000..47435414 --- /dev/null +++ b/tests/test-graph/src/features/test_readme_states_the_ttsc_shard_release_boundary.ts @@ -0,0 +1,40 @@ +import { TestValidator } from "@nestia/e2e"; +import fs from "node:fs"; +import path from "node:path"; + +import { GraphPaths } from "../internal/GraphPaths"; + +/** + * The published ttsc release predates native shard negotiation, so installation + * prose must not advertise that binary as satisfying the strict route. + * + * 1. Read both source and packaged README projections. + * 2. Require the legacy fallback boundary and pending producer link. + * 3. Retain the independently pinned Go corroboration command. + */ +export const test_readme_states_the_ttsc_shard_release_boundary = () => { + const fallback = + "`ttsc@0.23.0` provides the ordinary `ttscserver` fallback but predates this strict protocol"; + const producer = "native shard producer PR"; + const goInstall = + "go install github.com/scip-code/scip-go/cmd/scip-go@v0.2.7"; + for (const readme of [ + path.join(GraphPaths.repositoryRoot, "README.md"), + path.join(GraphPaths.graphPackageRoot, "README.md"), + ]) { + const text = fs.readFileSync(readme, "utf8"); + const label = path.relative(GraphPaths.repositoryRoot, readme); + TestValidator.predicate( + `${label} states the published ttsc fallback boundary`, + text.includes(fallback), + ); + TestValidator.predicate( + `${label} links the pending native producer`, + text.includes(producer), + ); + TestValidator.predicate( + `${label} pins the Go navigation producer used by the bundled provider`, + text.includes(goInstall), + ); + } +}; diff --git a/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts b/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts new file mode 100644 index 00000000..4f316e52 --- /dev/null +++ b/tests/test-graph/src/features/test_repository_context_adapters_preserve_authoritative_models.ts @@ -0,0 +1,1510 @@ +import { TestValidator } from "@nestia/e2e"; +import { + RepositoryContextProtocol, + SamchonRepositoryContextMemory, + cargoRepositoryContextProvider, + cmakeRepositoryContextProvider, + gradleRepositoryContextProvider, + pnpmRepositoryContextProvider, +} from "@samchon/graph"; +import fs from "node:fs"; +import path from "node:path"; + +import { GraphPaths } from "../internal/GraphPaths"; +import { parseGradleRepositoryContextModel } from "../../../../packages/graph/src/repository/parseGradleRepositoryContextModel"; + +/** + * Each adapter reads a different owning tool, and the tempting failure is the + * same in all four: when the model is missing, stale, or refuses to answer, + * reconstruct the topology from directory layout and publish it as though the + * tool had said it. This pins the opposite behaviour per ecosystem. Detection + * is keyed to the owning manifest rather than to any repository that happens + * to contain a folder. An absent Tooling API classpath, a failed tool, + * malformed JSON, a stale CMake reply and a missing File API query each make + * the adapter throw rather than answer. A Gradle module name that resolves + * ambiguously degrades `depends-on` to partial coverage with a warning instead + * of emitting the edge it cannot prove. And pnpm — the one ecosystem here that + * mixes both grades in a single model — keeps `declared` and `tool-resolved` + * counted apart across its nodes and edges. + */ +export const test_repository_context_adapters_preserve_authoritative_models = + async () => { + const root = GraphPaths.createTempDirectory( + "samchon-graph-repository-context-adapters-", + ); + try { + const pnpm = pnpmFixture(root); + const cargo = cargoFixture(root); + const gradle = gradleFixture(root); + const cmake = cmakeFixture(root); + + TestValidator.equals( + "repository-context adapters detect only their owning manifests", + [ + pnpmRepositoryContextProvider.detect(root), + cargoRepositoryContextProvider.detect(path.join(root, "cargo")), + gradleRepositoryContextProvider.detect(root), + cmakeRepositoryContextProvider.detect(path.join(root, "cmake")), + pnpmRepositoryContextProvider.detect(path.join(root, "absent")), + cargoRepositoryContextProvider.detect(path.join(root, "absent")), + gradleRepositoryContextProvider.detect(path.join(root, "absent")), + cmakeRepositoryContextProvider.detect(path.join(root, "absent")), + ], + [true, true, true, true, false, false, false, false], + ); + for (const [provider, providerRoot] of [ + [pnpmRepositoryContextProvider, root], + [cargoRepositoryContextProvider, path.join(root, "cargo")], + [gradleRepositoryContextProvider, root], + [cmakeRepositoryContextProvider, path.join(root, "cmake")], + ] as const) { + const session = provider.open({ + root: providerRoot, + env: process.env, + }); + TestValidator.equals( + `${provider.name} opens at generation zero`, + session.generation, + 0, + ); + await session.close(); + await TestValidator.error( + `${provider.name} refuses refresh after close`, + () => session.refresh(), + ); + } + + TestValidator.equals( + "pnpm preserves members, local dependencies, roots, tasks and entrypoints", + summarize(pnpm), + { + ecosystem: "pnpm", + nodeKinds: [ + "entrypoint", + "entrypoint", + "generated-root", + "package", + "package", + "source-root", + "source-root", + "task", + "workspace", + ], + edgeKinds: [ + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "depends-on", + "entrypoint-of", + "entrypoint-of", + "joins-file", + "joins-file", + "source-of", + "source-of", + "source-of", + ], + files: ["apps/app/src/index.ts", "packages/lib/src/index.ts"], + coverage: 8, + }, + ); + TestValidator.equals( + "pnpm distinguishes tool-resolved workspace facts from declared manifest facts", + authoritySummary(pnpm), + { + nodes: { declared: 8, "tool-resolved": 1 }, + edges: { declared: 13, "tool-resolved": 3 }, + }, + ); + TestValidator.equals( + "Cargo preserves packages, targets, dependencies, tests and source joins", + summarize(cargo), + { + ecosystem: "cargo", + nodeKinds: [ + "build-target", + "build-target", + "entrypoint", + "entrypoint", + "package", + "package", + "source-set", + "source-set", + "workspace", + ], + edgeKinds: [ + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "depends-on", + "entrypoint-of", + "entrypoint-of", + "joins-file", + "joins-file", + "joins-file", + "joins-file", + "source-of", + "source-of", + "test-of", + ], + files: ["cargo/app/src/main.rs", "cargo/lib/src/lib.rs"], + coverage: 8, + }, + ); + TestValidator.equals( + "Gradle Tooling API preserves projects, project dependencies, tasks and roots", + summarize(gradle), + { + ecosystem: "gradle", + nodeKinds: [ + "build-target", + "build-target", + "project", + "project", + "source-root", + "source-root", + "task", + "task", + "workspace", + ], + edgeKinds: [ + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "depends-on", + "source-of", + "source-of", + "test-of", + ], + files: [], + coverage: 8, + }, + ); + TestValidator.equals( + "Gradle source roots join against the current code generation without rescanning the Tooling API model", + joinedFiles(gradle, [ + "gradle/app/src/main/App.java", + "gradle/lib/src/test/LibTest.java", + ]), + [ + "gradle/app/src/main/App.java", + "gradle/lib/src/test/LibTest.java", + ], + ); + TestValidator.equals( + "code-file create, rename and delete recompute joins without changing the topology model", + [ + joinedFiles(gradle, ["gradle/app/src/main/Created.java"]), + joinedFiles(gradle, ["gradle/app/src/main/Renamed.java"]), + joinedFiles(gradle, []), + ], + [ + ["gradle/app/src/main/Created.java"], + ["gradle/app/src/main/Renamed.java"], + [], + ], + ); + TestValidator.equals( + "CMake File API preserves projects, targets, sources, artifacts and entrypoints", + summarize(cmake), + { + ecosystem: "cmake", + nodeKinds: [ + "build-target", + "entrypoint", + "generated-root", + "generated-root", + "project", + "source-root", + "workspace", + ], + edgeKinds: [ + "contains", + "contains", + "contains", + "contains", + "contains", + "contains", + "entrypoint-of", + "joins-file", + "joins-file", + "produces", + "source-of", + "source-of", + ], + files: [ + "cmake/build/generated/gen.c", + "cmake/src/main.c", + ], + coverage: 8, + }, + ); + TestValidator.equals( + "all first-slice adapters retain exhaustive topology coverage", + [pnpm, cargo, gradle, cmake].map( + (collection) => collection.shards[0]!.coverage.length, + ), + [8, 8, 8, 8], + ); + TestValidator.predicate( + "code contents are joins, not topology model inputs", + ![...pnpm.shards[0]!.sources, ...cargo.shards[0]!.sources].some( + (source) => + source.file.endsWith(".ts") || source.file.endsWith(".rs"), + ), + ); + TestValidator.equals( + "Cargo feature selections participate in repository identity", + cargo.shards[0]!.nodes + .filter((node) => node.name === "app") + .map((node) => node.configuration), + ["features=cli", "features=cli", "features=cli"], + ); + TestValidator.equals( + "exact manifest files synthesize joins against the current code generation", + joinedFiles(pnpm, [ + "apps/app/src/index.ts", + "packages/lib/src/index.ts", + ]), + [ + "apps/app/src/index.ts", + "apps/app/src/index.ts", + "packages/lib/src/index.ts", + "packages/lib/src/index.ts", + ], + ); + + const rootJoin = structuredClone(gradle); + rootJoin.shards[0]!.nodes[0]!.root = "."; + TestValidator.equals( + "a repository-root fact joins every current code file", + joinedFiles(rootJoin, ["at-root.ts", "nested/file.ts"]), + ["at-root.ts", "nested/file.ts"], + ); + TestValidator.equals( + "a query retains an inbound declared dependency edge", + topologyMemory(pnpm) + .inspect( + { + type: "topology", + query: "@fixture/lib", + relations: ["depends-on"], + }, + { + state: "unavailable", + topologyInputGeneration: "input", + codeInputGeneration: "code", + }, + ) + .edges.map((edge) => edge.kind), + ["depends-on"], + ); + + const ambiguousGradle = gradleAmbiguousDependencyFixture(root); + TestValidator.equals( + "an ambiguous Gradle module name degrades dependency coverage instead of inventing an edge", + [ + ambiguousGradle.shards[0]!.edges.some( + (edge) => edge.kind === "depends-on", + ), + ambiguousGradle.shards[0]!.coverage.find( + (row) => row.family === "depends-on", + )?.state, + ambiguousGradle.warnings.some((warning) => + warning.includes("ambiguous"), + ), + ], + [false, "partial", true], + ); + TestValidator.predicate( + "Gradle preserves generated roots and resolves one unambiguous module name", + gradleEdgeFixture(root).shards[0]!.edges.some( + (edge) => edge.kind === "depends-on", + ), + ); + TestValidator.predicate( + "pnpm preserves fallback identities, nested exports and non-path dependency rows", + pnpmEdgeFixture(root).shards[0]!.nodes.some( + (node) => node.kind === "entrypoint" && node.name.startsWith("exports"), + ), + ); + TestValidator.equals( + "pnpm falls back to package.json evidence when no workspace manifest is present", + pnpmNoWorkspaceFixture(root).shards[0]!.nodes[0]!.evidence?.file, + "package.json", + ); + TestValidator.predicate( + "Cargo distinguishes default configurations, external packages and unresolved metadata rows", + cargoEdgeFixtures(root).every( + (collection) => collection.shards[0]!.nodes.length > 1, + ), + ); + + TestValidator.error( + "Gradle Tooling API evaluation requires explicit opt-in", + () => + gradleRepositoryContextProvider.collect( + { root, env: process.env }, + () => ({ version: "", modules: [] }), + ), + ); + TestValidator.error( + "Gradle reports a missing Tooling API classpath without downloading it", + () => + gradleRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + GRADLE_HOME: undefined, + SAMCHON_GRAPH_GRADLE_TOOLING_CLASSPATH: undefined, + SAMCHON_GRAPH_ALLOW_GRADLE_MODEL: "1", + }, + }), + ); + for (const env of [ + { + ...process.env, + SAMCHON_GRAPH_ALLOW_GRADLE_MODEL: "1", + SAMCHON_GRAPH_GRADLE_TOOLING_CLASSPATH: path.join(root, "missing.jar"), + JAVA_HOME: path.join(root, "missing-java"), + }, + { + ...process.env, + SAMCHON_GRAPH_ALLOW_GRADLE_MODEL: "1", + SAMCHON_GRAPH_GRADLE_TOOLING_CLASSPATH: undefined, + GRADLE_HOME: path.join(root, "missing-gradle"), + JAVA_HOME: path.join(root, "missing-java"), + }, + { + ...process.env, + SAMCHON_GRAPH_ALLOW_GRADLE_MODEL: "1", + SAMCHON_GRAPH_GRADLE_TOOLING_CLASSPATH: path.join(root, "missing.jar"), + JAVA_HOME: undefined, + }, + ]) { + TestValidator.error( + "Gradle surfaces a Tooling API process failure without a fallback", + () => gradleRepositoryContextProvider.collect({ root, env }), + ); + } + exerciseGradleModelParser(root); + + const toolDirectory = path.join(root, "tools"); + installFakeRepositoryTool(toolDirectory, "pnpm"); + installFakeRepositoryTool(toolDirectory, "cargo"); + const toolEnv = { + ...process.env, + PATH: `${toolDirectory}${path.delimiter}${process.env.PATH ?? ""}`, + }; + TestValidator.predicate( + "the pnpm process boundary accepts a valid resolved workspace model", + pnpmRepositoryContextProvider.collect({ + root, + env: { + ...toolEnv, + FIXTURE_TOOL_MODEL: JSON.stringify(pnpmModel(root)), + }, + }).shards[0]!.nodes.length > 1, + ); + TestValidator.predicate( + "the Cargo process boundary accepts a valid offline metadata model", + cargoRepositoryContextProvider.collect({ + root: path.join(root, "cargo"), + env: { + ...toolEnv, + FIXTURE_TOOL_MODEL: JSON.stringify(cargoModel(root)), + }, + }).shards[0]!.nodes.length > 1, + ); + for (const provider of [ + pnpmRepositoryContextProvider, + cargoRepositoryContextProvider, + ] as const) { + const providerRoot = + provider === pnpmRepositoryContextProvider + ? root + : path.join(root, "cargo"); + TestValidator.error(`${provider.name} rejects a failed tool`, () => + provider.collect({ + root: providerRoot, + env: { + ...toolEnv, + FIXTURE_TOOL_MODE: "failed", + }, + }), + ); + TestValidator.error(`${provider.name} rejects malformed tool JSON`, () => + provider.collect({ + root: providerRoot, + env: { + ...toolEnv, + FIXTURE_TOOL_MODE: "malformed", + }, + }), + ); + } + for (const [provider, invalidModels] of [ + [ + pnpmRepositoryContextProvider, + [JSON.stringify([{ path: "" }])], + ], + [ + cargoRepositoryContextProvider, + [ + JSON.stringify({ + packages: [], + workspace_members: "invalid", + workspace_root: "", + }), + JSON.stringify({ + packages: [], + workspace_members: [], + workspace_root: 1, + }), + ], + ], + ] as const) { + const providerRoot = + provider === pnpmRepositoryContextProvider + ? root + : path.join(root, "cargo"); + for (const model of invalidModels) { + TestValidator.error(`${provider.name} validates every model field`, () => + provider.collect({ + root: providerRoot, + env: { + ...toolEnv, + FIXTURE_TOOL_MODEL: model, + }, + }), + ); + } + } + for (const [provider, providerRoot, model] of [ + [pnpmRepositoryContextProvider, root, pnpmModel(root)], + [ + cargoRepositoryContextProvider, + path.join(root, "cargo"), + cargoModel(root), + ], + ] as const) { + TestValidator.equals( + `${provider.name} reports an unavailable version probe honestly`, + provider.collect({ + root: providerRoot, + env: { + ...toolEnv, + FIXTURE_TOOL_MODE: "version-failed", + FIXTURE_TOOL_MODEL: JSON.stringify(model), + }, + }).toolVersion, + "", + ); + } + + exerciseCmakeRefusals(root); + + const aborted = new AbortController(); + aborted.abort(); + for (const operation of [ + () => + pnpmRepositoryContextProvider.collect( + { root, env: process.env, signal: aborted.signal }, + () => [], + ), + () => + cargoRepositoryContextProvider.collect( + { root, env: process.env, signal: aborted.signal }, + () => cargoModel(root), + ), + () => + gradleRepositoryContextProvider.collect( + { + root, + env: { + ...process.env, + SAMCHON_GRAPH_ALLOW_GRADLE_MODEL: "1", + }, + signal: aborted.signal, + }, + () => ({ version: "1", modules: [] }), + ), + () => + cmakeRepositoryContextProvider.collect({ + root, + env: process.env, + signal: aborted.signal, + }), + ]) { + TestValidator.error("an adapter refuses a cancelled collection", operation); + } + + const cmakeList = path.join(root, "cmake", "CMakeLists.txt"); + const future = new Date(Date.now() + 2_000); + fs.utimesSync(cmakeList, future, future); + TestValidator.error( + "a stale CMake File API model is refused rather than joined to changed configuration", + () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: path.join( + root, + "cmake", + "build", + ".cmake", + "api", + "v1", + "reply", + ), + }, + }), + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }; + +function pnpmFixture(root: string) { + const app = path.join(root, "apps", "app"); + const library = path.join(root, "packages", "lib"); + write(path.join(root, "pnpm-workspace.yaml"), "packages:\n - apps/*\n - packages/*\n"); + write(path.join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + writeJson(path.join(root, "package.json"), { + name: "workspace", + private: true, + }); + writeJson(path.join(app, "package.json"), { + name: "@fixture/app", + files: ["src", "dist"], + main: "src/index.ts", + scripts: { build: "fixture" }, + }); + write(path.join(app, "src", "index.ts"), "export const app = 1;\n"); + writeJson(path.join(library, "package.json"), { + name: "@fixture/lib", + files: ["src"], + exports: "./src/index.ts", + }); + write(path.join(library, "src", "index.ts"), "export const lib = 1;\n"); + return pnpmRepositoryContextProvider.collect( + { root, env: process.env }, + () => pnpmModel(root), + ); +} + +function pnpmModel(root: string) { + return [ + { + name: "@fixture/app", + path: path.join(root, "apps", "app"), + dependencies: { + "@fixture/lib": { path: path.join(root, "packages", "lib") }, + }, + }, + { name: "@fixture/lib", path: path.join(root, "packages", "lib") }, + ]; +} + +function pnpmEdgeFixture(root: string) { + const first = path.join(root, "edge", "first"); + const second = path.join(root, "edge", "second"); + writeJson(path.join(first, "package.json"), { + files: ["", "*", "!private", path.resolve(first, "absolute"), "src"], + typings: "types.d.ts", + bin: "cli.js", + exports: { + ".": { + import: "esm.js", + ignored: null, + }, + "./feature": "feature.js", + }, + }); + writeJson(path.join(second, "package.json"), { + bin: { second: "second.js" }, + }); + return pnpmRepositoryContextProvider.collect( + { root, env: process.env }, + () => [ + { + path: first, + dependencies: { missingPath: {} }, + devDependencies: { absentWorkspace: { path: path.join(root, "absent") } }, + }, + { name: "fallback-name", path: second }, + ], + ); +} + +function pnpmNoWorkspaceFixture(root: string) { + const workspace = path.join(root, "pnpm-no-workspace"); + writeJson(path.join(workspace, "package.json"), { + name: "no-workspace-manifest", + }); + return pnpmRepositoryContextProvider.collect( + { root: workspace, env: process.env }, + () => [{ name: "no-workspace-manifest", path: workspace }], + ); +} + +function cargoFixture(root: string) { + const workspace = path.join(root, "cargo"); + const app = path.join(workspace, "app"); + const library = path.join(workspace, "lib"); + write(path.join(workspace, "Cargo.toml"), "[workspace]\nmembers=[]\n"); + write(path.join(workspace, "Cargo.lock"), ""); + write(path.join(app, "Cargo.toml"), "[package]\nname='app'\nversion='1.0.0'\n"); + write(path.join(app, "src", "main.rs"), "fn main() {}\n"); + write(path.join(library, "Cargo.toml"), "[package]\nname='lib'\nversion='1.0.0'\n"); + write(path.join(library, "src", "lib.rs"), "#[test] fn works() {}\n"); + return cargoRepositoryContextProvider.collect( + { root, env: process.env }, + () => cargoModel(root), + ); +} + +function cargoModel(root: string) { + const workspace = path.join(root, "cargo"); + const app = path.join(workspace, "app"); + const library = path.join(workspace, "lib"); + return { + workspace_root: workspace, + workspace_members: ["app 1", "lib 1"], + packages: [ + { + id: "app 1", + name: "app", + version: "1.0.0", + manifest_path: path.join(app, "Cargo.toml"), + targets: [ + { + name: "app", + kind: ["bin"], + crate_types: ["bin"], + src_path: path.join(app, "src", "main.rs"), + }, + ], + }, + { + id: "lib 1", + name: "lib", + version: "1.0.0", + manifest_path: path.join(library, "Cargo.toml"), + targets: [ + { + name: "lib-test", + kind: ["test"], + crate_types: ["bin"], + src_path: path.join(library, "src", "lib.rs"), + }, + ], + }, + ], + resolve: { + nodes: [ + { id: "app 1", dependencies: ["lib 1"], features: ["cli"] }, + { id: "lib 1", dependencies: [] }, + ], + }, + }; +} + +function cargoEdgeFixtures(root: string) { + const workspace = path.join(root, "cargo"); + const external = path.join(root, "cargo-external"); + write( + path.join(external, "Cargo.toml"), + "[package]\nname='external'\nversion='1.0.0'\n", + ); + write(path.join(external, "src", "lib.rs"), "pub fn library() {}\n"); + write(path.join(external, "examples", "demo.rs"), "fn main() {}\n"); + const model = cargoModel(root); + const externalPackage = { + id: "external 1", + name: "external", + version: "1.0.0", + manifest_path: path.join(external, "Cargo.toml"), + targets: [ + { + name: "library", + kind: ["lib"], + crate_types: ["lib"], + src_path: path.join(external, "src", "lib.rs"), + }, + { + name: "demo", + kind: ["example"], + crate_types: ["bin"], + src_path: path.join(external, "examples", "demo.rs"), + }, + ], + }; + return [ + cargoRepositoryContextProvider.collect( + { root: workspace, env: process.env }, + () => ({ + ...model, + workspace_root: workspace, + packages: [externalPackage], + workspace_members: [], + resolve: null, + }), + ), + cargoRepositoryContextProvider.collect( + { root: workspace, env: process.env }, + () => ({ + ...model, + workspace_root: workspace, + packages: [...model.packages, externalPackage], + resolve: { + nodes: [ + ...model.resolve.nodes, + { id: "absent 1", dependencies: ["external 1"] }, + ], + }, + }), + ), + ]; +} + +function gradleAmbiguousDependencyFixture(root: string) { + const directory = path.join(root, "gradle"); + return gradleRepositoryContextProvider.collect( + { + root, + env: { + ...process.env, + SAMCHON_GRAPH_ALLOW_GRADLE_MODEL: "1", + }, + }, + () => ({ + version: "9.1", + modules: [ + { + path: ":app", + name: "app", + directory, + dependencies: ["shared"], + sources: [], + tasks: [], + }, + { + path: ":left", + name: "shared", + directory, + dependencies: [], + sources: [], + tasks: [], + }, + { + path: ":right", + name: "shared", + directory, + dependencies: [], + sources: [], + tasks: [], + }, + ], + }), + ); +} + +function gradleFixture(root: string) { + const workspace = path.join(root, "gradle"); + const app = path.join(workspace, "app"); + const library = path.join(workspace, "lib"); + write(path.join(root, "settings.gradle.kts"), "rootProject.name = \"fixture\"\n"); + write(path.join(app, "build.gradle.kts"), ""); + write(path.join(library, "build.gradle.kts"), ""); + write(path.join(app, "src", "main", "App.java"), "class App {}\n"); + write(path.join(library, "src", "test", "LibTest.java"), "class LibTest {}\n"); + return gradleRepositoryContextProvider.collect( + { + root, + env: { + ...process.env, + SAMCHON_GRAPH_ALLOW_GRADLE_MODEL: "1", + }, + }, + () => ({ + version: "9.1", + modules: [ + { + path: ":app", + name: "app", + directory: app, + dependencies: [":lib"], + sources: [ + { + kind: "source", + directory: path.join(app, "src", "main"), + generated: false, + }, + ], + tasks: [{ path: ":app:build", name: "build" }], + }, + { + path: ":lib", + name: "lib", + directory: library, + dependencies: [], + sources: [ + { + kind: "test", + directory: path.join(library, "src", "test"), + generated: false, + }, + ], + tasks: [{ path: ":lib:test", name: "test" }], + }, + ], + }), + ); +} + +function gradleEdgeFixture(root: string) { + const workspace = path.join(root, "gradle-edge"); + const app = path.join(workspace, "app"); + const library = path.join(workspace, "library"); + write(path.join(workspace, "settings.gradle"), "rootProject.name='edge'\n"); + write(path.join(app, "build.gradle"), ""); + write(path.join(library, "build.gradle"), ""); + return gradleRepositoryContextProvider.collect( + { + root: workspace, + env: { + ...process.env, + SAMCHON_GRAPH_ALLOW_GRADLE_MODEL: "1", + }, + }, + () => ({ + version: "9.1", + modules: [ + { + path: ":app", + name: "app", + directory: app, + dependencies: ["library"], + sources: [ + { + kind: "generated", + directory: path.join(app, "build", "generated"), + generated: true, + }, + ], + tasks: [], + }, + { + path: ":library", + name: "library", + directory: library, + dependencies: [], + sources: [], + tasks: [], + }, + ], + }), + ); +} + +function cmakeFixture(root: string) { + const workspace = path.join(root, "cmake"); + const reply = path.join(workspace, "build", ".cmake", "api", "v1", "reply"); + write(path.join(workspace, "CMakeLists.txt"), "add_executable(app src/main.c)\n"); + write(path.join(workspace, "src", "main.c"), "int main(void) { return 0; }\n"); + writeJson(path.join(reply, "index-1.json"), { + cmake: { version: { string: "4.0.0" } }, + reply: { + "codemodel-v2": { jsonFile: "codemodel.json" }, + "cmakeFiles-v1": { jsonFile: "cmakeFiles.json" }, + }, + }); + writeJson(path.join(reply, "cmakeFiles.json"), { + paths: { source: workspace, build: path.join(workspace, "build") }, + inputs: [{ path: "CMakeLists.txt" }], + }); + writeJson(path.join(reply, "codemodel.json"), { + paths: { source: workspace, build: path.join(workspace, "build") }, + configurations: [ + { + name: "Debug", + projects: [{ name: "fixture", directoryIndexes: [0], targetIndexes: [0] }], + directories: [ + { + source: ".", + build: ".", + projectIndex: 0, + targetIndexes: [0], + }, + ], + targets: [ + { + name: "app", + id: "app::1", + directoryIndex: 0, + projectIndex: 0, + jsonFile: "target-app.json", + }, + ], + }, + ], + }); + writeJson(path.join(reply, "target-app.json"), { + name: "app", + id: "app::1", + type: "EXECUTABLE", + paths: { source: workspace, build: path.join(workspace, "build") }, + sources: [ + { path: "src/main.c" }, + { + path: path.join(workspace, "build", "generated", "gen.c"), + isGenerated: true, + }, + ], + dependencies: [], + artifacts: [{ path: "bin/app" }], + }); + return cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: reply, + }, + }); +} + +function summarize( + collection: ReturnType, +) { + const shard = collection.shards[0]!; + return { + ecosystem: shard.nodes[0]!.ecosystem, + nodeKinds: shard.nodes.map((node) => node.kind).sort(), + edgeKinds: shard.edges.map((edge) => edge.kind).sort(), + files: shard.files, + coverage: shard.coverage.length, + }; +} + +function joinedFiles( + collection: ReturnType, + codeFiles: readonly string[], +): string[] { + return topologyMemory(collection) + .inspect( + { type: "topology", relations: ["joins-file"], limit: 500 }, + { + state: "compatible", + topologyInputGeneration: "input", + codeInputGeneration: "code", + }, + new Set(codeFiles), + ) + .edges.map((edge) => edge.to) + .sort(); +} + +function topologyMemory( + collection: ReturnType, +): SamchonRepositoryContextMemory { + const shard = collection.shards[0]!; + const contentDigest = RepositoryContextProtocol.contentDigest(shard); + return new SamchonRepositoryContextMemory({ + project: ".", + schemaVersion: 1, + inputGeneration: "input", + generation: { + sequence: 1, + token: "topology", + shards: [ + { + key: shard.key, + digest: RepositoryContextProtocol.shardDigest(shard), + }, + ], + contentDigest, + }, + provenance: [], + coverage: shard.coverage, + nodes: shard.nodes, + edges: shard.edges, + files: shard.files, + sources: shard.sources, + warnings: [], + }); +} + +function exerciseGradleModelParser(root: string): void { + const encode = (value: string): string => + Buffer.from(value, "utf8").toString("base64url"); + const row = (kind: string, ...fields: string[]): string => + [kind, ...fields.map(encode)].join("\t"); + const output = [ + "", + row("V", "9.1"), + row("M", ":app", "app", root), + row("D", ":app", ":lib"), + row("S", ":app", "main", path.join(root, "src"), "true"), + row("T", ":app", ":app:build", "build"), + ].join("\r\n"); + TestValidator.equals( + "the Gradle sidecar framing preserves every supported record", + parseGradleRepositoryContextModel(output), + { + version: "9.1", + modules: [ + { + path: ":app", + name: "app", + directory: root, + dependencies: [":lib"], + sources: [ + { + kind: "main", + directory: path.join(root, "src"), + generated: true, + }, + ], + tasks: [{ path: ":app:build", name: "build" }], + }, + ], + }, + ); + for (const malformed of [ + "", + row("V", "9.1"), + row("M", ":app", "app"), + [row("V", "9.1"), row("D", ":absent", ":lib")].join("\n"), + [row("V", "9.1"), row("S", ":absent", "main", root, "false")].join( + "\n", + ), + [row("V", "9.1"), row("T", ":absent", ":task", "task")].join("\n"), + [row("V", "9.1"), row("X", "unknown")].join("\n"), + ]) { + TestValidator.error("malformed Gradle sidecar framing is refused", () => + parseGradleRepositoryContextModel(malformed), + ); + } +} + +function exerciseCmakeRefusals(root: string): void { + const absentRoot = path.join(root, "cmake-absent"); + write(path.join(absentRoot, "CMakeLists.txt"), "project(absent)\n"); + TestValidator.error("CMake never creates a missing File API query", () => + cmakeRepositoryContextProvider.collect({ + root: absentRoot, + env: process.env, + }), + ); + + const emptyReply = path.join( + root, + "cmake-empty", + ".cmake", + "api", + "v1", + "reply", + ); + fs.mkdirSync(emptyReply, { recursive: true }); + TestValidator.error("CMake requires an existing File API index", () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: emptyReply, + }, + }), + ); + + const missingReferences = cmakeScenario(root, "missing-references", { + index: {}, + configurations: [{ name: "", projects: [], directories: [], targets: [] }], + }); + TestValidator.error( + "CMake requires both codemodel-v2 and cmakeFiles-v1 replies", + () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: missingReferences, + }, + }), + ); + + const wrongReplyVersions = cmakeScenario(root, "wrong-reply-versions", { + index: { + reply: { + "codemodel-v20": { jsonFile: "codemodel.json" }, + "cmakeFiles-v10": { jsonFile: "cmakeFiles.json" }, + }, + }, + configurations: [{ name: "", projects: [], directories: [], targets: [] }], + }); + TestValidator.error( + "CMake stateless reply keys must match the requested major versions exactly", + () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: wrongReplyVersions, + }, + }), + ); + + const failedReply = cmakeScenario(root, "failed-latest-reply", { + configurations: [{ name: "", projects: [], directories: [], targets: [] }], + }); + writeJson(path.join(failedReply, "error-1.json"), { + error: "fixture same-generation failure", + }); + writeJson(path.join(failedReply, "error-9999.json"), { + error: "fixture configure failed", + }); + TestValidator.error( + "CMake refuses an error reply newer than the last successful index", + () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: failedReply, + }, + }), + ); + + const emptyConfigurations = cmakeScenario(root, "empty-configurations", { + configurations: [], + }); + TestValidator.error("CMake refuses an empty codemodel", () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: emptyConfigurations, + }, + }), + ); + + const configurations = [ + { name: "Debug", projects: [], directories: [], targets: [] }, + { name: "Release", projects: [], directories: [], targets: [] }, + ]; + const multiple = cmakeScenario(root, "multiple-configurations", { + configurations, + }); + TestValidator.error( + "CMake requires an explicit choice for multiple configurations", + () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: multiple, + }, + }), + ); + TestValidator.error("CMake refuses an absent requested configuration", () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: multiple, + SAMCHON_GRAPH_CMAKE_CONFIGURATION: "Absent", + }, + }), + ); + TestValidator.equals( + "CMake publishes only the explicitly selected configuration", + cmakeRepositoryContextProvider + .collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: multiple, + SAMCHON_GRAPH_CMAKE_CONFIGURATION: "Release", + }, + }) + .shards.map((shard) => shard.target), + ["Release"], + ); + + const objectReply = cmakeScenario(root, "object-references", { + index: { + objects: [ + { + kind: "codemodel", + version: { major: 2, minor: 8 }, + jsonFile: "codemodel.json", + }, + { + kind: "cmakeFiles", + version: { major: 1, minor: 1 }, + jsonFile: "cmakeFiles.json", + }, + ], + }, + configurations: [ + { + name: "", + projects: [{ name: "fixture", directoryIndexes: [], targetIndexes: [0, 1] }], + directories: [], + targets: [ + { + name: "app", + id: "app", + directoryIndex: 0, + projectIndex: 0, + jsonFile: "app.json", + }, + { + name: "library", + id: "library", + directoryIndex: 0, + projectIndex: 0, + jsonFile: "library.json", + }, + ], + }, + ], + targets: { + "app.json": { + name: "app", + id: "app", + type: "EXECUTABLE", + dependencies: [{ id: "library" }], + }, + "library.json": { + name: "library", + id: "library", + type: "STATIC_LIBRARY", + }, + }, + }); + const objectModel = cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: objectReply, + }, + }); + TestValidator.equals( + "CMake accepts the object index form, default configuration and target dependencies", + [ + objectModel.toolVersion, + objectModel.target, + objectModel.shards[0]!.edges.some( + (edge) => edge.kind === "depends-on", + ), + ], + ["", "default", true], + ); + + const wrongObjectVersions = cmakeScenario( + root, + "wrong-object-versions", + { + index: { + objects: [ + { + kind: "codemodel", + version: { major: 1, minor: 0 }, + jsonFile: "codemodel.json", + }, + { + kind: "cmakeFiles", + jsonFile: "cmakeFiles.json", + }, + ], + }, + configurations: [ + { name: "", projects: [], directories: [], targets: [] }, + ], + }, + ); + TestValidator.error( + "CMake object references must declare the requested reply major versions", + () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: wrongObjectVersions, + }, + }), + ); + + const includedInputReply = cmakeScenario(root, "included-input", { + configurations: [ + { name: "", projects: [], directories: [], targets: [] }, + ], + inputs: [ + { path: "CMakeLists.txt" }, + { path: "cmake/options.cmake" }, + ], + }); + const includedInput = path.join( + root, + "cmake-included-input", + "cmake", + "options.cmake", + ); + write(includedInput, "set(FIXTURE_OPTION ON)\n"); + const future = new Date(Date.now() + 2_000); + fs.utimesSync(includedInput, future, future); + TestValidator.error( + "CMake refuses a File API model older than any owning cmakeFiles input", + () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: includedInputReply, + }, + }), + ); + + const renamedInput = path.join( + root, + "cmake-renamed-input", + "cmake", + "options.cmake", + ); + write(renamedInput, "set(FIXTURE_OPTION ON)\n"); + const renamedInputReply = cmakeScenario(root, "renamed-input", { + configurations: [ + { name: "", projects: [], directories: [], targets: [] }, + ], + inputs: [ + { path: "CMakeLists.txt" }, + { path: "cmake/options.cmake" }, + ], + }); + fs.renameSync( + renamedInput, + path.join(path.dirname(renamedInput), "renamed-options.cmake"), + ); + TestValidator.error( + "CMake refuses a File API model whose owning input was renamed or deleted", + () => + cmakeRepositoryContextProvider.collect({ + root, + env: { + ...process.env, + SAMCHON_GRAPH_CMAKE_REPLY: renamedInputReply, + }, + }), + ); +} + +function cmakeScenario( + root: string, + name: string, + options: { + index?: Record; + configurations: Array>; + targets?: Record>; + inputs?: Array<{ path: string }>; + }, +): string { + const source = path.join(root, `cmake-${name}`); + const build = path.join(source, "build"); + const reply = path.join(build, ".cmake", "api", "v1", "reply"); + write(path.join(source, "CMakeLists.txt"), `project(${name})\n`); + writeJson(path.join(reply, "cmakeFiles.json"), { + paths: { source, build }, + inputs: options.inputs ?? [{ path: "CMakeLists.txt" }], + }); + writeJson(path.join(reply, "codemodel.json"), { + paths: { source, build }, + configurations: options.configurations, + }); + for (const [file, target] of Object.entries(options.targets ?? {})) { + writeJson(path.join(reply, file), { + paths: { source, build }, + ...target, + }); + } + writeJson(path.join(reply, "index-1.json"), { + ...(options.index ?? { + reply: { + "codemodel-v2": { jsonFile: "codemodel.json" }, + "cmakeFiles-v1": { jsonFile: "cmakeFiles.json" }, + }, + }), + }); + return reply; +} + +function installFakeRepositoryTool(directory: string, name: string): void { + fs.mkdirSync(directory, { recursive: true }); + const source = [ + "#!/usr/bin/env node", + 'const mode = process.env.FIXTURE_TOOL_MODE ?? "valid";', + 'if (process.argv.includes("--version")) { if (mode === "version-failed") process.exit(2); console.log("fixture 1.0.0"); process.exit(0); }', + 'if (mode === "failed") { console.error("fixture tool failed"); process.exit(2); }', + 'if (mode === "malformed") { console.log("{}"); process.exit(0); }', + 'console.log(process.env.FIXTURE_TOOL_MODEL ?? "{}");', + ].join("\n"); + if (process.platform === "win32") { + const script = path.join(directory, `${name}.cjs`); + write(script, source); + write( + path.join(directory, `${name}.cmd`), + `@node "%~dp0\\${name}.cjs" %*\r\n`, + ); + } else { + const executable = path.join(directory, name); + write(executable, source); + fs.chmodSync(executable, 0o755); + } +} + +function write(file: string, content: string): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +function writeJson(file: string, value: unknown): void { + write(file, JSON.stringify(value)); +} + +function authoritySummary( + collection: ReturnType, +): { + nodes: Record; + edges: Record; +} { + const count = ( + rows: readonly { authority: string }[], + ): Record => + Object.fromEntries( + [...rows] + .reduce((output, row) => { + output.set(row.authority, (output.get(row.authority) ?? 0) + 1); + return output; + }, new Map()) + .entries(), + ); + return { + nodes: count(collection.shards.flatMap((shard) => shard.nodes)), + edges: count(collection.shards.flatMap((shard) => shard.edges)), + }; +} diff --git a/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts b/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts new file mode 100644 index 00000000..7c5de2bf --- /dev/null +++ b/tests/test-graph/src/features/test_repository_context_protocol_commits_atomic_shards.ts @@ -0,0 +1,661 @@ +import { TestValidator } from "@nestia/e2e"; +import { + RepositoryContextProtocol, + repositoryContextFacts, +} from "@samchon/graph"; + +const { repositoryContextCoverage, repositoryContextId } = + repositoryContextFacts; + +/** + * The repository plane is a second protocol with the same atomicity promise as + * the code one, and nothing in the code protocol's tests reaches it. This pins + * the boundary that keeps the two planes from drifting apart: a published + * topology generation is frozen, a delta must extend exactly the current + * generation, coverage stays exhaustive over every relation family, `joins-file` + * is the one relation whose target is a file rather than a node, and version 1 + * refuses `inferred` authority outright rather than publishing a guessed build + * fact beside a tool-resolved one. + */ +export const test_repository_context_protocol_commits_atomic_shards = + async () => { + const store = new RepositoryContextProtocol.Store(); + const initialFrames = transaction(1); + const initial = store.apply(initialFrames); + TestValidator.equals( + "the initial repository context generation is complete", + [ + initial.generation.sequence, + initial.nodes.map((node) => node.kind), + initial.edges.map((edge) => edge.kind), + initial.coverage.length, + initial.files, + initial.sources, + ], + [ + 1, + ["workspace", "source-root"], + ["contains", "joins-file"], + RepositoryContextProtocol.RELATION_KINDS.length, + ["src/main.ts"], + [{ file: "workspace.json", digest: "a".repeat(64) }], + ], + ); + TestValidator.error("a published topology snapshot is immutable", () => { + initial.nodes.push(initial.nodes[0]!); + }); + TestValidator.error("conflicting duplicate manifest sources are refused", () => + RepositoryContextProtocol.manifestDigest([ + { file: "same", digest: "a".repeat(64) }, + { file: "same", digest: "b".repeat(64) }, + ]), + ); + TestValidator.error("an invalid initial base is refused", () => + new RepositoryContextProtocol.Store().apply( + transaction(2, initial), + ), + ); + + const unchanged = store.apply(transaction(2, initial)); + TestValidator.equals( + "a valid empty delta advances only the generation", + [ + unchanged.generation.sequence, + unchanged.generation.shards, + unchanged.nodes, + ], + [2, initial.generation.shards, initial.nodes], + ); + + const prior = store.current; + const invalid = [ + [] as RepositoryContextProtocol.Frame[], + mutate(transaction(3, unchanged), (frames) => { + frames[0] = frames.at(-1)!; + }), + mutate(transaction(3, unchanged), (frames) => { + frames[frames.length - 1] = frames[0]!; + }), + mutate(transaction(3, unchanged), (frames) => { + frames.pop(); + }), + mutate(transaction(3, unchanged), (frames) => { + (frames[1] as RepositoryContextProtocol.IBegin).baseSequence = 1; + }), + mutate(transaction(3, unchanged), (frames) => { + (frames.at(-1) as RepositoryContextProtocol.ICommit).generation = + "other"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = frames[2] as RepositoryContextProtocol.IUpsertShard; + upsert.digest = "b".repeat(64); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + frames.splice(3, 0, structuredClone(frames[2]!)); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = frames[2] as RepositoryContextProtocol.IUpsertShard; + upsert.shard.edges[0]!.to = "missing"; + refresh(frames); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const hello = frames[0] as RepositoryContextProtocol.IHello; + hello.authority = "inferred"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const hello = frames[0] as RepositoryContextProtocol.IHello; + (hello as { authority: string }).authority = "guessed"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = frames[2] as RepositoryContextProtocol.IUpsertShard; + (upsert.shard.nodes[0] as { kind: string }).kind = "solution"; + refresh(frames); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = frames[2] as RepositoryContextProtocol.IUpsertShard; + (upsert.shard.nodes[0] as { authority: string }).authority = + "guessed"; + refresh(frames); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = frames[2] as RepositoryContextProtocol.IUpsertShard; + upsert.shard.nodes[0]!.authority = "inferred"; + refresh(frames); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = frames[2] as RepositoryContextProtocol.IUpsertShard; + (upsert.shard.coverage[0] as { state: string }).state = "unknown"; + refresh(frames); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const begin = frames[1] as RepositoryContextProtocol.IBegin; + begin.manifest = "c".repeat(64); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const hello = frames[0] as RepositoryContextProtocol.IHello; + hello.toolVersion = "changed"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const hello = frames[0] as RepositoryContextProtocol.IHello; + hello.protocolVersion = 0 as 1; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const hello = frames[0] as RepositoryContextProtocol.IHello; + hello.provider = ""; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const hello = frames[0] as RepositoryContextProtocol.IHello; + hello.supportedFamilies.push("contains"); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const hello = frames[0] as RepositoryContextProtocol.IHello; + (hello.supportedFamilies as string[]).push("invented"); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const hello = frames[0] as RepositoryContextProtocol.IHello; + hello.capabilities.push("fixture"); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const begin = frames[1] as RepositoryContextProtocol.IBegin; + begin.sequence = 0; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const begin = frames[1] as RepositoryContextProtocol.IBegin; + begin.generation = ""; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const begin = frames[1] as RepositoryContextProtocol.IBegin; + begin.manifest = "invalid"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const begin = frames[1] as RepositoryContextProtocol.IBegin; + delete begin.baseGeneration; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const begin = frames[1] as RepositoryContextProtocol.IBegin; + begin.baseSequence = 0; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const begin = frames[1] as RepositoryContextProtocol.IBegin; + begin.baseGeneration = ""; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.key = ""; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.target = "other"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.nodes[0]!.name = ""; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.nodes[0]!.ecosystem = "other"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.nodes.push(structuredClone(upsert.shard.nodes[0]!)); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.nodes[0]!.root = "src"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.nodes[1]!.root = ""; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.nodes[1]!.file = ""; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.nodes[0]!.evidence = { file: "", startLine: 1 }; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.nodes[0]!.evidence = { + file: "workspace.json", + startLine: 0, + }; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + (upsert.shard.edges[0] as { kind: string }).kind = "invokes"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + (upsert.shard.edges[0] as { authority: string }).authority = + "guessed"; + refresh(frames); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.edges[0]!.authority = "inferred"; + refresh(frames); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.edges[0]!.from = ""; + }), + // The only edge endpoint that is checked against the file list rather + // than the node list. `from` always resolves through nodes whatever the + // kind, and the sibling mutation above blanks a `from` and is refused + // earlier still, by shard validation. So this arm — a `joins-file` + // naming a file the shard never declared — is reached by nothing else, + // and it is how the topology plane would start pointing at code the code + // generation has no record of. + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.edges.find((edge) => edge.kind === "joins-file")!.to = + "src/undeclared.ts"; + refresh(frames); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.edges.push(structuredClone(upsert.shard.edges[0]!)); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.coverage.push( + structuredClone(upsert.shard.coverage[0]!), + ); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.coverage.pop(); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.files.push(upsert.shard.files[0]!); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.sources[0]!.digest = "invalid"; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const upsert = changedUpsert(frames); + upsert.shard.sources.push( + structuredClone(upsert.shard.sources[0]!), + ); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + frames.splice(2, 0, { + type: "deleteShard", + key: "absent", + }); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + frames.splice(2, 0, frames[0]!); + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const commit = frames.at(-1) as RepositoryContextProtocol.ICommit; + commit.shards = []; + }), + mutate(transaction(3, unchanged, changedShard()), (frames) => { + const commit = frames.at(-1) as RepositoryContextProtocol.ICommit; + commit.contentDigest = "0".repeat(64); + }), + ]; + for (const frames of invalid) { + TestValidator.error( + "a malformed repository context transaction is rejected", + () => store.apply(frames), + ); + TestValidator.equals( + "a rejected transaction retains the prior generation", + store.current, + prior, + ); + } + const aborted = new AbortController(); + aborted.abort(); + TestValidator.error("a cancelled transaction is rejected", () => + store.apply(transaction(3, unchanged, changedShard()), { + signal: aborted.signal, + }), + ); + + const changed = store.apply(transaction(3, unchanged, changedShard())); + TestValidator.equals( + "a changed shard replaces one atomic topology generation", + [changed.generation.sequence, changed.nodes.at(-1)?.name], + [3, "source"], + ); + + const multiStore = new RepositoryContextProtocol.Store(); + const first = validShard(); + const second = secondaryShard(); + const multi = multiStore.apply(initialTransaction(1, [first, second])); + const deleted = multiStore.apply(deleteTransaction(2, multi, first, second)); + TestValidator.equals( + "a valid delete delta removes exactly one committed shard", + [ + multi.generation.shards.map((shard) => shard.key), + deleted.generation.shards.map((shard) => shard.key), + deleted.nodes.map((node) => node.name), + ], + [ + ["fixture:secondary", "fixture:workspace"], + ["fixture:workspace"], + ["fixture", "src"], + ], + ); + + const conflictingSource = secondaryShard(); + const conflictingFrames = initialTransaction(1, [ + validShard(), + conflictingSource, + ]); + const conflictingUpsert = conflictingFrames.find( + (frame): frame is RepositoryContextProtocol.IUpsertShard => + frame.type === "upsertShard" && + frame.shard.key === conflictingSource.key, + )!; + conflictingUpsert.shard.sources = [ + { file: "workspace.json", digest: "b".repeat(64) }, + ]; + conflictingUpsert.digest = RepositoryContextProtocol.shardDigest( + conflictingUpsert.shard, + ); + const conflictingCommit = conflictingFrames.at( + -1, + ) as RepositoryContextProtocol.ICommit; + conflictingCommit.shards = conflictingFrames + .filter( + (frame): frame is RepositoryContextProtocol.IUpsertShard => + frame.type === "upsertShard", + ) + .map((frame) => ({ key: frame.shard.key, digest: frame.digest })) + .sort((left, right) => + left.key < right.key ? -1 : left.key > right.key ? 1 : 0, + ); + TestValidator.error("cross-shard source disagreement is refused", () => + new RepositoryContextProtocol.Store().apply(conflictingFrames), + ); + const duplicateNode = secondaryShard(); + duplicateNode.nodes = [structuredClone(validShard().nodes[0]!)]; + TestValidator.error("cross-shard duplicate nodes are refused", () => + new RepositoryContextProtocol.Store().apply( + initialTransaction(1, [validShard(), duplicateNode]), + ), + ); + const duplicateEdge = secondaryShard(); + duplicateEdge.nodes = []; + duplicateEdge.edges = [structuredClone(validShard().edges[0]!)]; + TestValidator.error("cross-shard duplicate edges are refused", () => + new RepositoryContextProtocol.Store().apply( + initialTransaction(1, [validShard(), duplicateEdge]), + ), + ); + }; + +function transaction( + sequence: number, + base?: RepositoryContextProtocol.ISnapshot, + shard?: RepositoryContextProtocol.IShard, +): RepositoryContextProtocol.Frame[] { + const selected = shard ?? validShard(); + const manifest = RepositoryContextProtocol.manifestDigest(selected.sources); + const generation = `generation-${String(sequence)}`; + const includeShard = + base === undefined || + RepositoryContextProtocol.shardDigest(selected) !== + base.generation.shards[0]?.digest; + return [ + hello(), + { + type: "begin", + sequence, + generation, + ...(base !== undefined + ? { + baseSequence: base.generation.sequence, + baseGeneration: base.generation.token, + } + : {}), + inputGeneration: RepositoryContextProtocol.digest({ sequence, manifest }), + universe: "fixture-universe", + target: "workspace", + manifest, + }, + ...(includeShard + ? [ + { + type: "upsertShard" as const, + digest: RepositoryContextProtocol.shardDigest(selected), + shard: selected, + }, + ] + : []), + { + type: "commit", + sequence, + generation, + shards: [ + { + key: selected.key, + digest: RepositoryContextProtocol.shardDigest(selected), + }, + ], + contentDigest: RepositoryContextProtocol.contentDigest(selected), + }, + ]; +} + +function changedUpsert( + frames: RepositoryContextProtocol.Frame[], +): RepositoryContextProtocol.IUpsertShard { + return frames.find( + (frame): frame is RepositoryContextProtocol.IUpsertShard => + frame.type === "upsertShard", + )!; +} + +function hello(): RepositoryContextProtocol.IHello { + return { + type: "hello", + protocolVersion: 1, + schemaVersion: 1, + producerSchemaVersion: 1, + provider: "fixture-context", + ecosystem: "fixture", + authority: "declared", + tool: "fixture-model", + toolVersion: "1.0.0", + supportedFamilies: ["contains", "joins-file"], + capabilities: ["fixture"], + }; +} + +function validShard(): RepositoryContextProtocol.IShard { + const workspace = repositoryContextId("fixture", "workspace", "."); + const source = repositoryContextId("fixture", "source-root", "src"); + return { + key: "fixture:workspace", + target: "workspace", + nodes: [ + { + id: workspace, + authority: "declared", + kind: "workspace", + name: "fixture", + ecosystem: "fixture", + coordinate: ".", + configuration: "default", + external: false, + }, + { + id: source, + authority: "declared", + kind: "source-root", + name: "src", + ecosystem: "fixture", + coordinate: "src", + configuration: "default", + external: false, + }, + ], + edges: [ + { + authority: "declared", + kind: "contains", + from: workspace, + to: source, + }, + { + authority: "declared", + kind: "joins-file", + from: source, + to: "src/main.ts", + }, + ], + coverage: repositoryContextCoverage( + "fixture-context", + "fixture", + "workspace", + ["contains", "joins-file"], + ), + files: ["src/main.ts"], + sources: [{ file: "workspace.json", digest: "a".repeat(64) }], + }; +} + +function changedShard(): RepositoryContextProtocol.IShard { + const shard = validShard(); + shard.nodes[1]!.name = "source"; + return shard; +} + +function secondaryShard(): RepositoryContextProtocol.IShard { + const project = repositoryContextId("fixture", "project", "secondary"); + return { + key: "fixture:secondary", + target: "workspace", + nodes: [ + { + id: project, + authority: "declared", + kind: "project", + name: "secondary", + ecosystem: "fixture", + coordinate: "secondary", + configuration: "default", + external: false, + }, + ], + edges: [], + coverage: repositoryContextCoverage( + "fixture-context", + "fixture", + "workspace", + ["contains", "joins-file"], + ), + files: [], + sources: [{ file: "secondary.json", digest: "b".repeat(64) }], + }; +} + +function initialTransaction( + sequence: number, + shards: readonly RepositoryContextProtocol.IShard[], +): RepositoryContextProtocol.Frame[] { + const sources = shards.flatMap((shard) => shard.sources); + const manifest = RepositoryContextProtocol.manifestDigest(sources); + const generation = `multi-generation-${String(sequence)}`; + return [ + hello(), + { + type: "begin", + sequence, + generation, + inputGeneration: RepositoryContextProtocol.digest({ sequence, manifest }), + universe: "fixture-universe", + target: "workspace", + manifest, + }, + ...shards.map((shard) => ({ + type: "upsertShard" as const, + digest: RepositoryContextProtocol.shardDigest(shard), + shard, + })), + { + type: "commit", + sequence, + generation, + shards: shards + .map((shard) => ({ + key: shard.key, + digest: RepositoryContextProtocol.shardDigest(shard), + })) + .sort((left, right) => + left.key < right.key ? -1 : left.key > right.key ? 1 : 0, + ), + contentDigest: RepositoryContextProtocol.contentDigest({ + nodes: shards.flatMap((shard) => shard.nodes), + edges: shards.flatMap((shard) => shard.edges), + coverage: shards.flatMap((shard) => shard.coverage), + }), + }, + ]; +} + +function deleteTransaction( + sequence: number, + base: RepositoryContextProtocol.ISnapshot, + retained: RepositoryContextProtocol.IShard, + removed: RepositoryContextProtocol.IShard, +): RepositoryContextProtocol.Frame[] { + const generation = `multi-generation-${String(sequence)}`; + const manifest = RepositoryContextProtocol.manifestDigest(retained.sources); + return [ + hello(), + { + type: "begin", + sequence, + generation, + baseSequence: base.generation.sequence, + baseGeneration: base.generation.token, + inputGeneration: RepositoryContextProtocol.digest({ sequence, manifest }), + universe: "fixture-universe", + target: "workspace", + manifest, + }, + { type: "deleteShard", key: removed.key }, + { + type: "commit", + sequence, + generation, + shards: [ + { + key: retained.key, + digest: RepositoryContextProtocol.shardDigest(retained), + }, + ], + contentDigest: RepositoryContextProtocol.contentDigest(retained), + }, + ]; +} + +function mutate( + frames: RepositoryContextProtocol.Frame[], + operation: (frames: RepositoryContextProtocol.Frame[]) => void, +): RepositoryContextProtocol.Frame[] { + const cloned = structuredClone(frames); + operation(cloned); + return cloned; +} + +function refresh(frames: RepositoryContextProtocol.Frame[]): void { + const upsert = frames.find( + (frame): frame is RepositoryContextProtocol.IUpsertShard => + frame.type === "upsertShard", + )!; + upsert.digest = RepositoryContextProtocol.shardDigest(upsert.shard); + const commit = frames.at(-1) as RepositoryContextProtocol.ICommit; + commit.shards = [{ key: upsert.shard.key, digest: upsert.digest }]; + commit.contentDigest = RepositoryContextProtocol.contentDigest(upsert.shard); +} diff --git a/tests/test-graph/src/features/test_resident_repository_context_is_atomic_and_retryable.ts b/tests/test-graph/src/features/test_resident_repository_context_is_atomic_and_retryable.ts new file mode 100644 index 00000000..1dfb19ec --- /dev/null +++ b/tests/test-graph/src/features/test_resident_repository_context_is_atomic_and_retryable.ts @@ -0,0 +1,732 @@ +import { TestValidator } from "@nestia/e2e"; +import { + IRepositoryContextProvider, + RepositoryContextProtocol, + createRepositoryContextSession, + createResidentRepositoryContextMemorySource, + createResidentRepositoryContextSource, + repositoryContextFacts, + validateRepositoryContextProviders, +} from "@samchon/graph"; +import fs from "node:fs"; +import path from "node:path"; + +import { GraphPaths } from "../internal/GraphPaths"; + +const { + repositoryContextCoverage, + repositoryContextId, + repositoryContextSource, +} = repositoryContextFacts; + +/** + * A repository model is expensive enough that the resident source is expected + * to reuse it, and that expectation is exactly what makes a failed provider + * dangerous: reusing the last good model after the build files moved would + * serve a topology no checkout has. This pins the input fence around that reuse + * — an unchanged input generation reuses the snapshot, a moved one recollects, + * a provider that throws contributes unsupported coverage and a warning instead + * of removing the plane, and a later success recovers without the failure + * leaving residue in the merged generation. + */ +export const test_resident_repository_context_is_atomic_and_retryable = + async () => { + const root = GraphPaths.createTempDirectory( + "samchon-graph-resident-repository-context-", + ); + const input = path.join(root, "context.json"); + let invocations = 0; + let failure = false; + try { + write(input, { name: "initial", file: "src/main.ts" }); + write(path.join(root, "src", "main.ts"), {}); + fs.mkdirSync(path.join(root, "members"), { recursive: true }); + const provider = fakeProvider(() => { + invocations += 1; + if (failure) throw new Error("fixture model failed"); + const model = JSON.parse(fs.readFileSync(input, "utf8")) as { + name: string; + file: string; + }; + return collection(root, model); + }); + const resident = createResidentRepositoryContextSource( + root, + process.env, + [provider], + ); + const initial = await resident.load(); + const unchanged = await resident.load(); + TestValidator.equals( + "a validated no-op reuses the exact topology generation without invoking the model", + [ + sourceName(initial), + initial.generation.sequence, + unchanged === initial, + invocations, + ], + ["initial", 1, true, 1], + ); + + write(input, { name: "changed", file: "src/main.ts" }); + const changed = await resident.load(); + TestValidator.equals( + "a manifest edit replaces one atomic topology generation", + [ + sourceName(changed), + changed.generation.sequence, + invocations, + ], + ["changed", 2, 2], + ); + + write(input, { name: "broken", file: "src/main.ts" }); + failure = true; + const unavailable = await resident.load(); + TestValidator.equals( + "a changed provider input that cannot be modeled publishes explicit unavailability without stale facts", + [ + sourceName(unavailable), + unavailable.coverage.every( + (row) => + row.provider === "fixture-context" && + row.target === "unavailable" && + row.state === "unsupported", + ), + unavailable.warnings.some((warning) => + warning.includes("fixture model failed"), + ), + unavailable.generation.sequence, + ], + [undefined, true, true, 3], + ); + const sameFailure = await resident.load(); + TestValidator.equals( + "an identical repeated failure does not publish another generation", + [sameFailure === unavailable, sameFailure.generation.sequence], + [true, 3], + ); + + write(input, { name: "still-broken", file: "src/main.ts" }); + const movedFailure = await resident.load(); + TestValidator.equals( + "a different failed input still advances the unavailable generation", + [ + movedFailure === unavailable, + movedFailure.generation.sequence, + sourceName(movedFailure), + ], + [false, 4, undefined], + ); + + failure = false; + write(input, { name: "recovered", file: "src/main.ts" }); + const recovered = await resident.load(); + TestValidator.equals( + "the next successful retry atomically replaces retained context", + [ + sourceName(recovered), + recovered.warnings.length, + recovered.generation.sequence, + ], + ["recovered", 0, 5], + ); + + write(input, { name: "cancelled", file: "src/main.ts" }); + const aborted = new AbortController(); + aborted.abort(); + await TestValidator.error("a cancelled topology refresh rejects", () => + resident.load({ signal: aborted.signal }), + ); + TestValidator.equals( + "cancellation leaves the prior generation reachable", + sourceName(await resident.load()), + "cancelled", + ); + + const createdMember = path.join(root, "members", "created"); + const renamedMember = path.join(root, "members", "renamed"); + fs.mkdirSync(createdMember); + const afterCreate = await resident.load(); + fs.renameSync(createdMember, renamedMember); + const afterRename = await resident.load(); + fs.rmdirSync(renamedMember); + const afterDelete = await resident.load(); + TestValidator.equals( + "member create, rename and delete each replace one complete input generation", + [ + afterCreate.generation.sequence, + afterRename.generation.sequence, + afterDelete.generation.sequence, + sourceName(afterDelete), + ], + [7, 8, 9, "cancelled"], + ); + + await resident.close(); + await TestValidator.error( + "a closed topology source refuses new loads", + () => resident.load(), + ); + TestValidator.error("duplicate registry names are refused", () => + validateRepositoryContextProviders([provider, provider]), + ); + TestValidator.error("blank registry ecosystems are refused", () => + validateRepositoryContextProviders([ + { ...provider, name: "blank-ecosystem", ecosystem: "" }, + ]), + ); + TestValidator.error("empty registry relation contracts are refused", () => + validateRepositoryContextProviders([ + { ...provider, name: "empty-families", families: [] }, + ]), + ); + + const memoryResident = { + load: async () => initial, + close: async () => {}, + }; + const loadMemory = + createResidentRepositoryContextMemorySource(memoryResident); + const memoryOne = await loadMemory(); + const memoryTwo = await loadMemory(); + TestValidator.equals( + "resident topology memory is reused for the exact dump identity", + memoryOne === memoryTwo, + true, + ); + memoryResident.load = async () => recovered; + TestValidator.equals( + "a replacement dump receives a replacement topology memory", + (await loadMemory()) === memoryOne, + false, + ); + + TestValidator.error("adapter source disagreement is refused", () => + repositoryContextFacts.uniqueRepositorySources([ + { file: "same", digest: "a".repeat(64) }, + { file: "same", digest: "b".repeat(64) }, + ]), + ); + + const movingInput = path.join(root, "moving.json"); + write(movingInput, { state: 1 }); + const movingProvider = fakeProvider( + () => { + const model = collection(root, { + name: "moving", + file: "src/main.ts", + }); + model.shards[0]!.sources = [ + repositoryContextSource(root, movingInput), + ]; + write(movingInput, { state: 2 }); + return model; + }, + ["moving.json"], + ); + const movingSession = movingProvider.open({ root, env: process.env }); + await TestValidator.error( + "a provider input moving during collection refuses the generation", + () => movingSession.refresh(), + ); + await movingSession.close(); + + const duplicateResident = createResidentRepositoryContextSource( + root, + process.env, + [provider, provider], + ); + await TestValidator.error( + "duplicate facts across providers are refused", + () => duplicateResident.load(), + ); + await duplicateResident.close(); + + const snapshotSession = provider.open({ root, env: process.env }); + const canonicalSnapshot = (await snapshotSession.refresh()).snapshot; + await snapshotSession.close(); + const disagreeingSnapshot = structuredClone(canonicalSnapshot); + disagreeingSnapshot.sources[0]!.digest = "f".repeat(64); + const disagreeingResident = createResidentRepositoryContextSource( + root, + process.env, + [ + snapshotProvider("source-left", canonicalSnapshot), + snapshotProvider("source-right", disagreeingSnapshot), + ], + ); + await TestValidator.error( + "provider source disagreement is refused before publication", + () => disagreeingResident.load(), + ); + await disagreeingResident.close(); + + const midRefreshAbort = new AbortController(); + const leftInitial = retargetSnapshot( + canonicalSnapshot, + "left-initial", + "left initial", + "1", + 1, + ); + const leftAdvanced = retargetSnapshot( + canonicalSnapshot, + "left-advanced", + "left advanced", + "2", + 2, + ); + const rightInitial = retargetSnapshot( + canonicalSnapshot, + "right-initial", + "right initial", + "3", + 1, + ); + const rightAdvanced = retargetSnapshot( + canonicalSnapshot, + "right-advanced", + "right advanced", + "4", + 2, + ); + const interruptedResident = createResidentRepositoryContextSource( + root, + process.env, + [ + advancingSnapshotProvider( + "advancing-left", + leftInitial, + leftAdvanced, + ), + advancingSnapshotProvider( + "advancing-right", + rightInitial, + rightAdvanced, + midRefreshAbort, + ), + ], + ); + const beforeInterruption = await interruptedResident.load(); + await TestValidator.error( + "a cancellation after one provider advances rejects the composite generation", + () => + interruptedResident.load({ signal: midRefreshAbort.signal }), + ); + const afterInterruption = await interruptedResident.load(); + TestValidator.equals( + "a retry publishes provider states committed before the cancelled composite refresh", + [ + sourceNames(beforeInterruption), + sourceNames(afterInterruption), + afterInterruption.generation.sequence, + ], + [ + ["left initial", "right initial"], + ["left advanced", "right advanced"], + 2, + ], + ); + await interruptedResident.close(); + + const closeFailure = createResidentRepositoryContextSource( + root, + process.env, + [closingProvider("close-error", "fixture close failed")], + ); + await TestValidator.error("provider close failures are surfaced", () => + closeFailure.close(), + ); + const multipleCloseFailures = createResidentRepositoryContextSource( + root, + process.env, + [ + closingProvider("close-first", new Error("first close failed")), + closingProvider("close-second", "second close failed"), + ], + ); + await TestValidator.error( + "the first provider close failure survives later close failures", + () => multipleCloseFailures.close(), + ); + + const nonErrorFailure = createResidentRepositoryContextSource( + root, + process.env, + [ + fakeProvider(() => { + throw "non-error model failure"; + }), + ], + ); + TestValidator.predicate( + "non-Error provider failures are normalized into explicit unavailability", + (await nonErrorFailure.load()).warnings.some((warning) => + warning.includes("non-error model failure"), + ), + ); + await nonErrorFailure.close(); + + const modeEnv = { ...process.env }; + const modeSession = provider.open({ root, env: modeEnv }); + TestValidator.equals( + "a first provider collection is initial", + (await modeSession.refresh()).mode, + "initial", + ); + modeEnv.PATH = `${modeEnv.PATH ?? ""}${path.delimiter}changed`; + TestValidator.equals( + "an environment-only input change reuses the same universe incrementally", + (await modeSession.refresh()).mode, + "incremental", + ); + await modeSession.close(); + + const noPathSession = provider.open({ + root, + env: { ...process.env, PATH: undefined }, + }); + await noPathSession.refresh(); + await noPathSession.close(); + TestValidator.predicate( + "root-relative input identity is stable without PATH", + createRepositoryContextSession.observeInputGeneration( + root, + ["."], + undefined, + { PATH: undefined }, + ).length === 64, + ); + + let includeSecondShard = true; + const shardSession = createRepositoryContextSession( + { + name: "shard-removal", + ecosystem: "fixture", + authority: "declared", + families: ["contains", "joins-file"], + buildInputs: ["context.json"], + }, + { root, env: process.env }, + () => { + const result = collection(root, { + name: "sharded", + file: "src/main.ts", + }); + if (includeSecondShard) { + result.shards.push({ + key: "fixture:secondary", + target: "workspace", + nodes: [ + { + id: repositoryContextId("fixture", "project", "secondary"), + authority: "declared", + kind: "project", + name: "secondary", + ecosystem: "fixture", + coordinate: "secondary", + configuration: "default", + external: false, + }, + ], + edges: [], + coverage: repositoryContextCoverage( + "shard-removal", + "fixture", + "workspace", + ["contains", "joins-file"], + ), + files: [], + sources: [repositoryContextSource(root, "context.json")], + }); + } + result.shards[0]!.coverage = repositoryContextCoverage( + "shard-removal", + "fixture", + "workspace", + ["contains", "joins-file"], + ); + return result; + }, + ); + TestValidator.equals( + "the initial session can own multiple atomic shards", + (await shardSession.refresh()).snapshot.generation.shards.length, + 2, + ); + includeSecondShard = false; + write(input, { name: "one-shard", file: "src/main.ts" }); + TestValidator.equals( + "a later collection emits the removed shard delta", + (await shardSession.refresh()).snapshot.generation.shards.length, + 1, + ); + await shardSession.close(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }; + +function fakeProvider( + collect: IRepositoryContextProvider.Collector, + buildInputs: readonly string[] = [ + "context.json", + "undeclared-by-collector.json", + ], +): IRepositoryContextProvider { + const provider: IRepositoryContextProvider = { + name: "fixture-context", + ecosystem: "fixture", + authority: "declared", + families: ["contains", "joins-file"], + buildInputs, + detect: () => true, + open: (props) => + createRepositoryContextSession(provider, props, collect), + }; + return provider; +} + +function closingProvider( + name: string, + failure: unknown, +): IRepositoryContextProvider { + return { + name, + ecosystem: "fixture", + authority: "declared", + families: ["contains"], + buildInputs: [], + detect: () => true, + open: ({ root }) => ({ + kind: "repository-context", + provider: name, + ecosystem: "fixture", + root, + generation: 0, + current: undefined, + refresh: async () => { + throw new Error("unused"); + }, + close: async () => { + throw failure; + }, + }), + }; +} + +function snapshotProvider( + name: string, + snapshot: RepositoryContextProtocol.ISnapshot, +): IRepositoryContextProvider { + return { + name, + ecosystem: "fixture", + authority: "declared", + families: ["contains", "joins-file"], + buildInputs: [], + detect: () => true, + open: ({ root }) => ({ + kind: "repository-context", + provider: name, + ecosystem: "fixture", + root, + generation: snapshot.generation.sequence, + current: snapshot, + refresh: async () => ({ + changed: true, + generation: snapshot.generation.sequence, + mode: "full", + snapshot, + warnings: [], + }), + close: async () => {}, + }), + }; +} + +function advancingSnapshotProvider( + name: string, + initial: RepositoryContextProtocol.ISnapshot, + advanced: RepositoryContextProtocol.ISnapshot, + abort?: AbortController, +): IRepositoryContextProvider { + return { + name, + ecosystem: "fixture", + authority: "declared", + families: ["contains", "joins-file"], + buildInputs: [], + detect: () => true, + open: ({ root }) => { + let calls = 0; + let current = initial; + return { + kind: "repository-context", + provider: name, + ecosystem: "fixture", + root, + get generation() { + return current.generation.sequence; + }, + get current() { + return current; + }, + refresh: async () => { + calls += 1; + if (calls === 1) { + return refreshResult(initial, true); + } + if (calls === 2) { + current = advanced; + if (abort !== undefined) { + abort.abort(); + throw new Error("fixture cancelled after provider commit"); + } + return refreshResult(advanced, true); + } + return refreshResult(current, false); + }, + close: async () => {}, + }; + }, + }; +} + +function refreshResult( + snapshot: RepositoryContextProtocol.ISnapshot, + changed: boolean, +) { + return { + changed, + generation: snapshot.generation.sequence, + mode: "full" as const, + snapshot, + warnings: [], + }; +} + +function retargetSnapshot( + input: RepositoryContextProtocol.ISnapshot, + key: string, + sourceName: string, + digestCharacter: string, + sequence: number, +): RepositoryContextProtocol.ISnapshot { + const snapshot = structuredClone(input); + const identities = new Map( + snapshot.nodes.map((node) => [node.id, `${node.id}:${key}`]), + ); + for (const node of snapshot.nodes) { + node.id = identities.get(node.id)!; + if (node.kind === "source-root") node.name = sourceName; + } + for (const edge of snapshot.edges) { + edge.from = identities.get(edge.from) ?? edge.from; + edge.to = identities.get(edge.to) ?? edge.to; + } + snapshot.sources = [ + { + file: `${key}.json`, + digest: digestCharacter.repeat(64), + }, + ]; + snapshot.hello.provider = key; + snapshot.begin.sequence = sequence; + snapshot.begin.inputGeneration = digestCharacter.repeat(64); + snapshot.begin.manifest = digestCharacter.repeat(64); + snapshot.generation.sequence = sequence; + snapshot.generation.token = digestCharacter.repeat(64); + snapshot.generation.contentDigest = digestCharacter.repeat(64); + return snapshot; +} + +function collection( + root: string, + model: { name: string; file: string }, +): IRepositoryContextProvider.ICollection { + const workspace = repositoryContextId("fixture", "workspace", "."); + const source = repositoryContextId("fixture", "source-root", "src"); + const shard = { + key: "fixture:workspace", + target: "workspace", + nodes: [ + { + id: workspace, + authority: "declared", + kind: "workspace" as const, + name: "fixture", + ecosystem: "fixture", + coordinate: ".", + configuration: "default", + external: false, + }, + { + id: source, + authority: "declared", + kind: "source-root" as const, + name: model.name, + ecosystem: "fixture", + coordinate: "src", + configuration: "default", + external: false, + }, + ], + edges: [ + { + authority: "declared" as const, + kind: "contains" as const, + from: workspace, + to: source, + }, + { + authority: "declared" as const, + kind: "joins-file" as const, + from: source, + to: model.file, + }, + ], + coverage: repositoryContextCoverage( + "fixture-context", + "fixture", + "workspace", + ["contains", "joins-file"], + ), + files: [model.file], + sources: [ + repositoryContextSource(root, "context.json"), + repositoryContextSource(root, "members"), + ], + }; + return { + producerSchemaVersion: 1, + tool: "fixture-model", + toolVersion: "1.0.0", + capabilities: ["fixture"], + universe: RepositoryContextProtocol.digest(shard.sources), + target: "workspace", + shards: [shard], + warnings: [], + }; +} + +function write(file: string, value: unknown): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(value)); +} + +function sourceName(snapshot: { + nodes: readonly { kind: string; name: string }[]; +}): string | undefined { + return snapshot.nodes.find((node) => node.kind === "source-root")?.name; +} + +function sourceNames(snapshot: { + nodes: readonly { kind: string; name: string }[]; +}): string[] { + return snapshot.nodes + .filter((node) => node.kind === "source-root") + .map((node) => node.name) + .sort(); +} diff --git a/tests/test-graph/src/features/test_result_audits_before_the_facts.ts b/tests/test-graph/src/features/test_result_audits_before_the_facts.ts index c5f7485e..4f53f2ae 100644 --- a/tests/test-graph/src/features/test_result_audits_before_the_facts.ts +++ b/tests/test-graph/src/features/test_result_audits_before_the_facts.ts @@ -19,7 +19,7 @@ export const test_result_audits_before_the_facts = async () => { TestValidator.equals( "audit leads, then where it leaves the question, then the facts", Object.keys(overview), - ["audit", "next", "result"], + ["audit", "coverage", "unresolved", "next", "result"], ); TestValidator.equals("the overview is the whole answer", overview.next.action, "answer"); diff --git a/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts b/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts new file mode 100644 index 00000000..525a8304 --- /dev/null +++ b/tests/test-graph/src/features/test_rust_hir_client_restores_retries_and_fails_closed.ts @@ -0,0 +1,554 @@ +import { TestValidator } from "@nestia/e2e"; +import { + RUST_GRAPH_PRODUCER_COMMIT, + RustGraphClient, + buildLspGraph, + rustGraphProvider, +} from "@samchon/graph"; +import fs from "node:fs"; +import path from "node:path"; + +import { GraphPaths } from "../internal/GraphPaths.js"; + +/** + * A resident producer answers before it is ready and restarts underneath a live + * session, and neither condition is an error the caller may see as a fallback. + * This pins the client's side of that: a cancelled or content-modified response + * is retried until the ready deadline rather than published, a no-op returns + * the exact resident object rather than an equal copy, a rejected restart + * checkpoint discards the persisted generation instead of reusing it, and a + * checkpoint that cannot be written surfaces as a warning on the returned + * snapshot rather than as a failed refresh. + * + * The refusal cases assert the strictest available form of atomicity, which is + * why they run on fresh clients: a producer response the adapter rejects, and + * a generation the consumer contract refuses, must each leave `current` + * undefined. Nothing partially applied, not merely nothing published. + */ +export const test_rust_hir_client_restores_retries_and_fails_closed = async () => { + const root = GraphPaths.createTempDirectory("samchon-graph-rust-client-"); + const cacheRoot = GraphPaths.createTempDirectory("samchon-graph-rust-checkpoints-"); + fs.mkdirSync(path.join(root, "src")); + fs.writeFileSync(path.join(root, "src/lib.rs"), "pub fn answer() -> u8 { 42 }\n"); + + await assertResidentLifecycle(root, cacheRoot); + await assertCheckpointRejectionRecovers(root, cacheRoot); + await assertRetryBoundaries(root); + await assertCancellationBoundaries(root); + await assertPersistenceAndValidationBoundaries(root); + await assertClientOptionBoundaries(root); + await assertPublicCommitFence(root); + await assertPinnedResolution(root); +}; + +async function assertResidentLifecycle(root: string, cacheRoot: string): Promise { + const marker = path.join(root, "basic-closed.txt"); + const requestLog = path.join(root, "basic-requests.ndjson"); + let validations = 0; + const client = rustClient(root, cacheRoot, [ + `--marker=${marker}`, + `--request-log=${requestLog}`, + ], () => { + validations += 1; + }); + const initial = await client.refresh(); + const unchanged = await client.refresh(); + TestValidator.equals( + "the resident Rust client publishes and reuses one validated generation", + [ + initial.changed, + initial.mode, + initial.generation, + unchanged.changed, + unchanged.mode, + unchanged.snapshot === initial.snapshot, + client.current === initial.snapshot, + client.generation, + initial.snapshot.nodes.map((node) => [node.name, node.language]), + initial.snapshot.sources.has(path.join(root, "src/lib.rs")), + initial.snapshot.provenance.provider, + validations, + ], + [ + true, + "initial", + 1, + false, + "unchanged", + true, + true, + 1, + [ + ["dependency", "rust"], + ["answer", "rust"], + ], + true, + "samchon-rust-analyzer-hir", + 2, + ], + ); + await Promise.all([client.close(), client.close()]); + TestValidator.equals("the Rust LSP process closes through its handshake", fs.readFileSync(marker, "utf8"), "closed"); + await rejected("a closed Rust session refuses refresh", client.refresh(), "session is closed"); + + let restoredValidations = 0; + const restoredLog = path.join(root, "restored-requests.ndjson"); + const restored = rustClient(root, cacheRoot, [`--request-log=${restoredLog}`], () => { + restoredValidations += 1; + }); + TestValidator.predicate( + "a persisted checkpoint stays unpublished before the restarted producer validates it", + restored.current === undefined && restored.generation === 0 && restoredValidations === 0, + ); + const reuse = await restored.refresh(); + const params = readRequests(restoredLog)[0]!; + TestValidator.equals( + "a restart sends both the exact known generation and its producer checkpoint", + [ + reuse.changed, + reuse.mode, + reuse.generation, + params.knownGeneration, + params.checkpoint?.generation, + ], + [true, "initial", 1, params.checkpoint?.generation, params.checkpoint?.generation], + ); + await restored.close(); +} + +async function assertCheckpointRejectionRecovers( + root: string, + cacheRoot: string, +): Promise { + const requestLog = path.join(root, "rejected-checkpoint-requests.ndjson"); + const client = rustClient(root, cacheRoot, [ + "--reject-checkpoint", + `--request-log=${requestLog}`, + ]); + const refreshed = await client.refresh(); + const requests = readRequests(requestLog); + TestValidator.equals( + "a producer-rejected persisted checkpoint is discarded and rebuilt once", + [ + refreshed.changed, + refreshed.mode, + requests.length, + requests[0]?.checkpoint !== undefined, + requests[1]?.checkpoint, + requests[1]?.knownGeneration, + ], + [true, "initial", 2, true, undefined, undefined], + ); + await client.close(); +} + +async function assertRetryBoundaries(root: string): Promise { + const retrying = rustClient(root, isolatedCache(), ["--retry=1", "--content-modified=1"], undefined, { + readyTimeoutMs: 1_000, + }); + TestValidator.equals( + "ServerCancelled and ContentModified are retried until the producer is ready", + (await retrying.refresh()).changed, + true, + ); + await retrying.close(); + + const exhausted = rustClient(root, isolatedCache(), ["--retry=100"], undefined, { + readyTimeoutMs: 1, + }); + await rejected( + "the Rust readiness retry loop has a hard deadline", + exhausted.refresh(), + "did not become ready", + ); + await exhausted.close(); + + const internal = rustClient(root, isolatedCache(), ["--internal-error"]); + await rejected( + "an unrelated producer error is not disguised as readiness", + internal.refresh(), + "fixture internal failure", + ); + await internal.close(); + + const malformed = rustClient(root, isolatedCache(), ["--malformed"]); + await rejected( + "a malformed producer identity fails before publication", + malformed.refresh(), + "identity/commit mismatch", + ); + TestValidator.equals("a rejected producer response leaves no resident graph", malformed.current, undefined); + await malformed.close(); +} + +async function assertCancellationBoundaries(root: string): Promise { + const preAborted = rustClient(root, isolatedCache(), []); + const preAbort = new AbortController(); + preAbort.abort("pre-aborted fixture cancellation"); + await rejected( + "a pre-aborted Rust request never enters its session queue", + preAborted.refresh({ signal: preAbort.signal }), + "cancelled", + ); + await preAborted.close(); + + const initializeMarker = path.join(root, "initialize-abort-started.txt"); + const initializing = rustClient(root, isolatedCache(), [ + "--initialize-delay=100", + `--initialize-marker=${initializeMarker}`, + ]); + const initializeAbort = new AbortController(); + const cancelledInitialization = initializing.refresh({ + signal: initializeAbort.signal, + }); + await waitFor(() => fs.existsSync(initializeMarker)); + initializeAbort.abort("initialize fixture cancellation"); + await rejected( + "caller cancellation leaves the shared Rust initialization usable", + cancelledInitialization, + "cancelled", + ); + TestValidator.equals( + "a later refresh reuses and completes the initialization instead of inheriting its caller's cancellation", + (await initializing.refresh()).changed, + true, + ); + await initializing.close(); + + const failedInitialization = rustClient(root, isolatedCache(), [ + "--initialize-error", + ]); + await rejected( + "a producer initialization error crosses the caller-cancellation fence intact", + failedInitialization.refresh({ signal: new AbortController().signal }), + "fixture initialize failure", + ); + await rejected( + "a failed producer initialization remains a fatal session result", + failedInitialization.refresh(), + "fixture initialize failure", + ); + await failedInitialization.close(); + + const retryLog = path.join(root, "retry-abort-requests.ndjson"); + const retrySent = path.join(root, "retry-abort-sent.txt"); + const retryDelay = rustClient(root, isolatedCache(), [ + "--retry=100", + `--request-log=${retryLog}`, + `--retry-sent-marker=${retrySent}`, + ]); + const retryAbort = new AbortController(); + const retryRefresh = retryDelay.refresh({ signal: retryAbort.signal }); + await waitFor(() => fs.existsSync(retrySent)); + await new Promise((resolve) => setTimeout(resolve, 10)); + retryAbort.abort("retry-delay fixture cancellation"); + await rejected( + "Rust readiness backoff remains cancellable", + retryRefresh, + "cancelled", + ); + await retryDelay.close(); + + const activeLog = path.join(root, "active-abort-requests.ndjson"); + const active = rustClient(root, isolatedCache(), ["--hang", `--request-log=${activeLog}`]); + const activeAbort = new AbortController(); + const activeRefresh = active.refresh({ signal: activeAbort.signal }); + await waitFor(() => fs.existsSync(activeLog)); + activeAbort.abort("active fixture cancellation"); + await rejected("an active Rust request observes caller cancellation", activeRefresh, "aborted"); + await active.close(); + + const requestLog = path.join(root, "queued-abort-requests.ndjson"); + const queued = rustClient(root, isolatedCache(), ["--hang", `--request-log=${requestLog}`]); + const first = queued.refresh(); + await waitFor(() => fs.existsSync(requestLog)); + const queuedAbort = new AbortController(); + const second = queued.refresh({ signal: queuedAbort.signal }); + queuedAbort.abort("queued fixture cancellation"); + await rejected("a queued Rust request cancels without entering the producer", second, "cancelled"); + const firstRejected = rejected( + "closing the Rust session cancels its active request", + first, + "aborted", + ); + await queued.close(); + await firstRejected; + TestValidator.equals("the cancelled queued request never reached the producer", readRequests(requestLog).length, 1); +} + +async function assertPersistenceAndValidationBoundaries(root: string): Promise { + const cacheFile = path.join(isolatedCache(), "not-a-directory"); + fs.writeFileSync(cacheFile, "file"); + const nonPersistent = rustClient(root, cacheFile, []); + const published = await nonPersistent.refresh(); + TestValidator.predicate( + "checkpoint persistence failure is disclosed without discarding the validated resident graph", + published.snapshot.warnings.some((warning) => warning.includes("could not be persisted")), + ); + await nonPersistent.close(); + + const refused = rustClient(root, isolatedCache(), [], () => { + throw new Error("fixture consumer contract rejection"); + }); + await rejected( + "the consumer contract runs before cache persistence and publication", + refused.refresh(), + "fixture consumer contract rejection", + ); + TestValidator.equals("consumer rejection is atomic", refused.current, undefined); + await refused.close(); + + const refusedString = rustClient(root, isolatedCache(), [], () => { + throw "fixture consumer string rejection"; + }); + await rejected( + "a non-Error consumer rejection is normalized at the session boundary", + refusedString.refresh(), + "fixture consumer string rejection", + ); + await refusedString.close(); +} + +async function assertClientOptionBoundaries(root: string): Promise { + const options = new RustGraphClient({ + root, + cacheRoot: isolatedCache(), + command: process.execPath, + args: [ + GraphPaths.fakeRustGraphServer, + `--commit=${RUST_GRAPH_PRODUCER_COMMIT}`, + "--configuration-without-items", + "--expect-initialization-options", + ], + producerCommit: RUST_GRAPH_PRODUCER_COMMIT, + initializationOptions: { fixture: true }, + }); + TestValidator.equals( + "the Rust client forwards initialization options and answers configuration requests without items", + (await options.refresh()).changed, + true, + ); + await options.close(); + + const closing = rustClient(root, isolatedCache(), []); + const racedRefresh = closing.refresh(); + const racedRejection = rejected( + "closing before a queued Rust refresh starts fails at the session fence", + racedRefresh, + "session is closed", + ); + await closing.close(); + await racedRejection; +} + +async function assertPublicCommitFence(root: string): Promise { + const cacheRoot = isolatedCache(); + const priorCacheRoot = process.env.SAMCHON_GRAPH_CACHE_DIR; + process.env.SAMCHON_GRAPH_CACHE_DIR = cacheRoot; + try { + const result = await buildLspGraph( + { cwd: root, languages: ["rust"] }, + { + providers: [ + { + ...rustGraphProvider, + resolve: () => ({ + command: process.execPath, + args: [ + GraphPaths.fakeRustGraphServer, + `--commit=${RUST_GRAPH_PRODUCER_COMMIT}`, + ], + }), + }, + ], + }, + ); + TestValidator.equals( + "a disk-bound Rust HIR generation crosses the public commit fence", + [ + result.dump.provenance?.map((row) => row.provider), + result.dump.nodes.some((node) => node.name === "answer"), + result.dump.warnings.some((warning) => + warning.includes("does not bind the provider snapshot"), + ), + ], + [["samchon-rust-analyzer-hir"], true, false], + ); + } finally { + if (priorCacheRoot === undefined) delete process.env.SAMCHON_GRAPH_CACHE_DIR; + else process.env.SAMCHON_GRAPH_CACHE_DIR = priorCacheRoot; + } +} + +async function assertPinnedResolution(root: string): Promise { + const pinned = nodeShim(root, "pinned-rust-analyzer", RUST_GRAPH_PRODUCER_COMMIT); + const shallowPinned = nodeShim( + root, + "shallow-pinned-rust-analyzer", + RUST_GRAPH_PRODUCER_COMMIT, + ["--version-commit-length=7"], + ); + const tooShort = nodeShim( + root, + "too-short-rust-analyzer", + RUST_GRAPH_PRODUCER_COMMIT, + ["--version-commit-length=6"], + ); + const wrong = nodeShim(root, "wrong-rust-analyzer", "0000000000000000000000000000000000000000"); + const failing = nodeShim(root, "failing-rust-analyzer", RUST_GRAPH_PRODUCER_COMMIT, [ + "--fail-version", + ]); + const override = "SAMCHON_GRAPH_RUST_ANALYZER_HIR"; + const resolved = rustGraphProvider.resolve(root, { ...process.env, [override]: pinned }); + const shallowResolved = rustGraphProvider.resolve(root, { + ...process.env, + [override]: shallowPinned, + }); + const shortRejected = rustGraphProvider.resolve(root, { + ...process.env, + [override]: tooShort, + }); + const rejected = rustGraphProvider.resolve(root, { ...process.env, [override]: wrong }); + const failed = rustGraphProvider.resolve(root, { ...process.env, [override]: failing }); + TestValidator.equals( + "the HIR provider resolves only the exact disclosed producer commit", + [ + resolved !== undefined, + shallowResolved !== undefined, + shortRejected, + rejected, + failed, + rustGraphProvider.configuration?.(root, { [override]: pinned }), + ], + [ + true, + true, + undefined, + undefined, + undefined, + [ + `producer-commit=${RUST_GRAPH_PRODUCER_COMMIT}`, + `${override}=${pinned}`, + ], + ], + ); + TestValidator.equals( + "an absent Rust producer override remains explicit in build configuration", + rustGraphProvider.configuration?.(root, {}), + [ + `producer-commit=${RUST_GRAPH_PRODUCER_COMMIT}`, + `${override}=unconfigured`, + ], + ); + TestValidator.predicate( + "whole-program Rust generations explicitly refuse bounded or caller-owned LSP modes", + rustGraphProvider.refuse({ maxFiles: 1 })?.includes("maxFiles") === true && + rustGraphProvider.refuse({ server: "rust-analyzer" })?.includes("server") === true && + rustGraphProvider.refuse({ lspReferenceLimit: 1 })?.includes("lspReferenceLimit") === true && + rustGraphProvider.refuse({}) === undefined, + ); + const priorCacheRoot = process.env.SAMCHON_GRAPH_CACHE_DIR; + process.env.SAMCHON_GRAPH_CACHE_DIR = isolatedCache(); + try { + const session = rustGraphProvider.open({ + root, + command: resolved!, + languages: ["rust"], + options: {}, + }); + try { + const snapshot = await session.refresh(); + TestValidator.equals( + "the registered Rust provider opens the pinned producer under its declared contract", + [snapshot.changed, snapshot.snapshot.provenance.authority], + [true, "analyzer"], + ); + } finally { + await session.close(); + } + } finally { + if (priorCacheRoot === undefined) delete process.env.SAMCHON_GRAPH_CACHE_DIR; + else process.env.SAMCHON_GRAPH_CACHE_DIR = priorCacheRoot; + } +} + +function rustClient( + root: string, + cacheRoot: string, + args: readonly string[], + validate?: ConstructorParameters[0]["validate"], + timeouts: { requestTimeoutMs?: number; readyTimeoutMs?: number } = {}, +): RustGraphClient { + return new RustGraphClient({ + root, + cacheRoot, + command: process.execPath, + args: [ + GraphPaths.fakeRustGraphServer, + `--commit=${RUST_GRAPH_PRODUCER_COMMIT}`, + ...args, + ], + producerCommit: RUST_GRAPH_PRODUCER_COMMIT, + validate, + ...timeouts, + }); +} + +function isolatedCache(): string { + return GraphPaths.createTempDirectory("samchon-graph-rust-isolated-cache-"); +} + +function readRequests(file: string): Array<{ + knownGeneration?: string; + checkpoint?: { generation?: string }; +}> { + return fs + .readFileSync(file, "utf8") + .trim() + .split(/\r?\n/u) + .filter((line) => line !== "") + .map((line) => JSON.parse(line)); +} + +function nodeShim( + root: string, + name: string, + commit: string, + args: readonly string[] = [], +): string { + const directory = path.join(root, "shims"); + fs.mkdirSync(directory, { recursive: true }); + const file = path.join(directory, process.platform === "win32" ? `${name}.cmd` : name); + const invocation = [ + `"${process.execPath}"`, + `"${GraphPaths.fakeRustGraphServer}"`, + `--commit=${commit}`, + ...args, + ].join(" "); + fs.writeFileSync( + file, + process.platform === "win32" + ? `@echo off\r\n${invocation} %*\r\n` + : `#!/bin/sh\nexec ${invocation} "$@"\n`, + ); + if (process.platform !== "win32") fs.chmodSync(file, 0o755); + return file; +} + +async function rejected(label: string, promise: Promise, message: string): Promise { + let error: Error | undefined; + try { + await promise; + } catch (caught) { + error = caught instanceof Error ? caught : new Error(String(caught)); + } + TestValidator.predicate(label, error !== undefined && error.message.includes(message)); +} + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 5_000; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error("fixture condition timed out"); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} diff --git a/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts b/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts new file mode 100644 index 00000000..865494f5 --- /dev/null +++ b/tests/test-graph/src/features/test_rust_hir_snapshot_adapter_fences_generations.ts @@ -0,0 +1,885 @@ +import { TestValidator } from "@nestia/e2e"; +import { + GRAPH_EDGE_KINDS, + GraphSnapshotProtocol, + RUST_GRAPH_PRODUCER_COMMIT, + RustGraphCache, + RustGraphSnapshotAdapter, + type IRustGraphCacheState, + type IRustGraphShard, + type IRustGraphSnapshot, +} from "@samchon/graph"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import { GraphPaths } from "../internal/GraphPaths.js"; + +const COMMIT = RUST_GRAPH_PRODUCER_COMMIT; + +/** + * The adapter is the only place a producer-owned HIR generation becomes a + * public one, so every fence it applies is invisible from either side alone: + * the producer cannot see the prior committed generation, and the common store + * cannot see the raw shard digests the producer signed. This pins that seam — + * a delta on a stale base, an unchanged generation that lost its base, a raw + * manifest or generation digest the producer contradicts, and a restart + * checkpoint whose normalized frames are trusted only after the live producer + * revalidates its raw HIR facts. + */ +export const test_rust_hir_snapshot_adapter_fences_generations = () => { + const root = GraphPaths.createTempDirectory("samchon-graph-rust-adapter-"); + fs.mkdirSync(path.join(root, "src")); + fs.writeFileSync(path.join(root, "src/lib.rs"), "pub fn answer() -> u8 { 42 }\n"); + + const adapter = new RustGraphSnapshotAdapter(root, COMMIT); + const initialRaw = snapshot({ nodeName: "answer" }); + const initial = adapter.prepare(initialRaw); + if (!initial.changed) throw new Error("initial Rust generation did not change"); + const initialSnapshot = adapter.store.apply(initial.frames); + initial.commit(initialSnapshot); + TestValidator.equals( + "a full HIR response becomes one validated graph generation", + [ + initial.mode, + adapter.hasPersistedSnapshot, + adapter.persistedCheckpoint?.generation, + initialSnapshot.nodes.map((node) => [node.name, node.kind, node.external]), + initialSnapshot.edges.map((edge) => edge.kind), + initialSnapshot.diagnostics.map((diagnostic) => diagnostic.severity), + initialSnapshot.coverage?.length, + initialSnapshot.unresolved?.length, + initialSnapshot.provenance.provider, + initialSnapshot.provenance.compilerVersion.startsWith("rustc 1.95.0"), + initialSnapshot.provenance.capabilities.includes("diskDigests"), + initialSnapshot.sources.get(path.join(root, "src/lib.rs"))?.diskDigest, + initialSnapshot.nodes.every((node) => node.id.startsWith("@v2/rust/")), + initialSnapshot.edges.every( + (edge) => + initialSnapshot.nodes.some((node) => node.id === edge.from) && + initialSnapshot.nodes.some((node) => node.id === edge.to), + ), + ], + [ + "initial", + true, + initialRaw.generation, + [ + ["dependency", "external_symbol", true], + ["answer", "function", false], + ], + ["calls"], + ["warning"], + GRAPH_EDGE_KINDS.length, + GRAPH_EDGE_KINDS.length - 1, + "samchon-rust-analyzer-hir", + true, + true, + sourceDigest("pub fn answer() -> u8 { 42 }\n"), + true, + true, + ], + ); + + const unchangedRaw = snapshot({ + base: initialRaw, + upserts: [], + sequence: 2, + }); + const unchanged = adapter.prepare(unchangedRaw); + if (unchanged.changed) throw new Error("Rust producer no-op changed the graph"); + TestValidator.equals( + "a producer no-op preserves the exact published object", + [unchanged.changed, unchanged.mode, unchanged.snapshot === initialSnapshot], + [false, "unchanged", true], + ); + + const incrementalRaw = snapshot({ + base: initialRaw, + nodeName: "edited_answer", + sequence: 3, + }); + const incremental = adapter.prepare(incrementalRaw); + if (!incremental.changed) throw new Error("incremental Rust generation did not change"); + TestValidator.error( + "producer delta frames cannot validate against an empty store", + () => new GraphSnapshotProtocol.Store(root).apply(incremental.frames), + ); + TestValidator.equals( + "a producer delta carries a complete reconstruction for isolated validation", + new GraphSnapshotProtocol.Store(root).apply(incremental.state.frames).nodes.some( + (node) => node.name === "edited_answer", + ), + true, + ); + const incrementalSnapshot = adapter.store.apply(incremental.frames); + incremental.commit(incrementalSnapshot); + TestValidator.equals( + "a producer delta advances only from its exact raw base", + [incremental.mode, incrementalSnapshot.nodes.some((node) => node.name === "edited_answer")], + ["incremental", true], + ); + + const rebuiltRaw = snapshot({ nodeName: "rebuilt", sequence: 4 }); + const rebuilt = adapter.prepare(rebuiltRaw); + if (!rebuilt.changed) throw new Error("rebuilt Rust generation did not change"); + const rebuiltSnapshot = adapter.store.apply(rebuilt.frames); + rebuilt.commit(rebuiltSnapshot); + TestValidator.equals("a full response in one universe is a rebuild", rebuilt.mode, "rebuild"); + + const reloadedRaw = snapshot({ + nodeName: "reloaded", + sequence: 5, + universe: digest("universe-2"), + }); + const reloaded = adapter.prepare(reloadedRaw); + if (!reloaded.changed) throw new Error("reloaded Rust generation did not change"); + const reloadedSnapshot = adapter.store.apply(reloaded.frames); + reloaded.commit(reloadedSnapshot); + TestValidator.equals("a full response in another universe is a reload", reloaded.mode, "reload"); + + const restored = new RustGraphSnapshotAdapter(root, COMMIT, reloaded.state); + TestValidator.equals( + "a consumer checkpoint remains unpublished until the producer validates its raw state", + [ + restored.store.current?.protocol?.generation, + restored.persistedCheckpoint?.generation, + restored.hasPersistedSnapshot, + ], + [undefined, reloadedRaw.generation, false], + ); + const restoredRaw = snapshot({ + base: reloadedRaw, + upserts: [], + sequence: 6, + }); + const restoredPrepared = restored.prepare(restoredRaw); + if (!restoredPrepared.changed) { + throw new Error("validated Rust checkpoint did not reconstruct its graph"); + } + const restoredSnapshot = restoredPrepared.commit( + restored.store.apply(restoredPrepared.frames), + ); + TestValidator.equals( + "producer validation reconstructs public facts without trusting cached frames", + [restoredPrepared.mode, restoredSnapshot.nodes.map((node) => node.name)], + ["initial", ["dependency", "reloaded"]], + ); + restored.discardPersistedSnapshot(); + restored.discardPersistedSnapshot(); + TestValidator.equals( + "discarding a persisted checkpoint returns to an empty adapter", + [restored.hasPersistedSnapshot, restored.persistedCheckpoint], + [false, undefined], + ); + + assertAdapterRefusals(root, initialRaw, reloaded.state); + assertDeltaDeletionAndCrossShardRefusals(root); + assertNativeSyntheticIdentities(root); + assertOptionalProducerFields(root); + assertCacheFallback(root, reloaded.state); +}; + +function assertNativeSyntheticIdentities(root: string): void { + const raw = snapshot({ nodeName: "with_file" }); + const source = raw.upserts[0]!.source; + const fileId = `rust-file-v1|${source.length}:${source}`; + const exportId = "rust-export-v1|fixture-alias"; + raw.upserts[0]!.nodes.push({ + id: fileId, + kind: "file", + name: "lib.rs", + qualifiedName: null, + file: source, + external: false, + exported: false, + signature: null, + evidence: null, + }); + raw.upserts[0]!.nodes.push({ + id: exportId, + kind: "function", + name: "exported_answer", + qualifiedName: "fixture::exported_answer", + file: source, + external: false, + exported: true, + signature: "fn() -> u8", + evidence: null, + }); + raw.upserts[0]!.edges.push({ + from: fileId, + to: "rust-hir-v1|answer", + kind: "contains", + evidence: null, + }); + raw.upserts[0]!.edges.push({ + from: exportId, + to: "rust-hir-v1|answer", + kind: "references", + evidence: null, + }); + refresh(raw); + + const adapter = new RustGraphSnapshotAdapter(root, COMMIT); + const prepared = adapter.prepare(raw); + if (!prepared.changed) throw new Error("native Rust file fixture did not change"); + const adapted = adapter.store.apply(prepared.frames); + TestValidator.equals( + "producer-owned file identities survive native validation and adaptation", + [ + adapted.nodes.some((node) => node.kind === "file" && node.name === "lib.rs"), + adapted.nodes.some((node) => node.name === "exported_answer"), + adapted.edges.some((edge) => edge.kind === "contains"), + adapted.edges.some((edge) => edge.kind === "references"), + ], + [true, true, true, true], + ); +} + +function assertOptionalProducerFields(root: string): void { + const optional = snapshot({ nodeName: "optional" }); + optional.universe.configurations = []; + optional.upserts[0]!.edges[0]!.evidence = null; + optional.upserts[0]!.diagnostics[0]!.column = 7; + optional.upserts[0]!.diagnostics[0]!.severity = null; + optional.upserts[0]!.unresolved[0]!.candidates = [ + "rust-hir-v1|dependency", + "rust-hir-v1|unknown", + ]; + refresh(optional); + const adapter = new RustGraphSnapshotAdapter(root, COMMIT); + const prepared = adapter.prepare(optional); + if (!prepared.changed) throw new Error("optional Rust fixture did not change"); + const adapted = adapter.store.apply(prepared.frames); + TestValidator.equals( + "optional producer fields preserve absence, positions, and unresolved native identities", + [ + adapted.edges[0]?.evidence, + adapted.diagnostics[0]?.column, + adapted.diagnostics[0]?.severity, + adapted.unresolved?.[0]?.candidates?.some((candidate) => + candidate.endsWith("rust-hir-v1|unknown"), + ), + adapted.provenance.compilerVersion, + ], + [undefined, 7, undefined, true, "unavailable"], + ); + + const bundledUniverse = digest("bundled-universe"); + const bundledRaw = snapshot({ + universe: bundledUniverse, + upserts: [rawShard("bundled", bundledUniverse, "bundled:///rust/source")], + }); + const bundledAdapter = new RustGraphSnapshotAdapter(root, COMMIT); + const bundled = bundledAdapter.prepare(bundledRaw); + if (!bundled.changed) throw new Error("bundled Rust fixture did not change"); + const bundledSource = bundledAdapter + .store + .apply(bundled.frames) + .sources.get("bundled:///rust/source"); + TestValidator.equals( + "bundled producer sources retain their URI but never claim a host disk identity", + [bundledSource !== undefined, bundledSource?.diskDigest], + [true, ""], + ); + + TestValidator.error("a snapshot without shards cannot establish coverage", () => + new RustGraphSnapshotAdapter(root, COMMIT).prepare(snapshot({ upserts: [] })), + ); +} + +function assertAdapterRefusals( + root: string, + valid: IRustGraphSnapshot, + state: IRustGraphCacheState, +): void { + const rejects = (label: string, mutate: (value: IRustGraphSnapshot) => void): void => { + TestValidator.error(label, () => { + const candidate = structuredClone(valid); + mutate(candidate); + new RustGraphSnapshotAdapter(root, COMMIT).prepare(candidate); + }); + }; + TestValidator.error("a non-object producer response is refused", () => + new RustGraphSnapshotAdapter(root, COMMIT).prepare(null as unknown as IRustGraphSnapshot), + ); + rejects("an unknown producer protocol is refused", (value) => { + value.protocolVersion = 2; + }); + rejects("a producer commit mismatch is refused", (value) => { + value.producer.commit = "wrong"; + }); + rejects("an empty producer version is refused", (value) => { + value.producer.version = ""; + }); + rejects("a malformed universe is refused", (value) => { + value.universe.digest = "wrong"; + }); + rejects("duplicate workspace roots are refused", (value) => { + value.universe.workspaceRoots = ["same", "same"]; + }); + rejects("a malformed generation envelope is refused", (value) => { + value.sequence = 0; + }); + rejects("malformed producer phase telemetry is refused", (value) => { + value.phases.totalMillis = -1; + }); + rejects("a malformed base generation is refused", (value) => { + value.baseGeneration = "wrong"; + }); + rejects("a non-canonical manifest is refused", (value) => { + value.manifest.push({ ...value.manifest[0]! }); + }); + rejects("a non-canonical delete list is refused", (value) => { + value.deletes = ["same", "same"]; + }); + rejects("a stale producer base is refused", (value) => { + value.baseGeneration = digest("stale"); + }); + TestValidator.error("a generation cannot lose its base", () => { + const adapter = new RustGraphSnapshotAdapter(root, COMMIT); + const prepared = adapter.prepare(structuredClone(valid)); + if (!prepared.changed) throw new Error("initial Rust generation did not change"); + prepared.commit(adapter.store.apply(prepared.frames)); + adapter.prepare(structuredClone(valid)); + }); + rejects("a missing delete is refused", (value) => { + value.baseGeneration = null; + value.deletes = ["missing"]; + }); + rejects("a shard delta cannot collide with a delete", (value) => { + value.deletes = [value.upserts[0]!.key]; + }); + rejects("a raw manifest mismatch is refused", (value) => { + value.manifest[0]!.digest = digest("wrong"); + }); + rejects("a raw generation mismatch is refused", (value) => { + value.generation = digest("wrong"); + }); + rejects("a raw shard key must match target and source", (value) => { + value.upserts[0]!.key = `wrong\0${value.upserts[0]!.source}`; + refresh(value); + }); + rejects("a raw shard digest mismatch is refused", (value) => { + value.upserts[0]!.digest = digest("wrong"); + }); + rejects("an empty raw shard key is refused", (value) => { + value.upserts[0]!.key = ""; + refresh(value); + }); + rejects("an empty raw node identity is refused", (value) => { + value.upserts[0]!.nodes[0]!.id = ""; + refresh(value); + }); + rejects("a foreign raw node identity is refused", (value) => { + value.upserts[0]!.nodes[0]!.id = "foreign|node"; + refresh(value); + }); + rejects("malformed raw node flags are refused", (value) => { + (value.upserts[0]!.nodes[0] as unknown as { external: string }).external = + "false"; + refresh(value); + }); + rejects("malformed raw exported flags are refused", (value) => { + (value.upserts[0]!.nodes[0] as unknown as { exported: string }).exported = + "true"; + refresh(value); + }); + rejects("a malformed qualified node name is refused", (value) => { + ( + value.upserts[0]!.nodes[0] as unknown as { qualifiedName: number } + ).qualifiedName = 1; + refresh(value); + }); + rejects("a malformed node signature is refused", (value) => { + (value.upserts[0]!.nodes[0] as unknown as { signature: number }).signature = + 1; + refresh(value); + }); + rejects("malformed raw shard arrays are refused", (value) => { + (value.upserts[0] as unknown as { nodes: null }).nodes = null; + refresh(value); + }); + rejects("an unknown raw node kind is refused", (value) => { + value.upserts[0]!.nodes[0]!.kind = "unknown"; + refresh(value); + }); + rejects("an unsupported raw edge family is refused", (value) => { + value.upserts[0]!.edges[0]!.kind = "renders"; + refresh(value); + }); + rejects("a foreign raw edge identity is refused", (value) => { + value.upserts[0]!.edges[0]!.from = "foreign|node"; + refresh(value); + }); + rejects("an invalid diagnostic line is refused", (value) => { + value.upserts[0]!.diagnostics[0]!.line = 0; + refresh(value); + }); + rejects("an invalid diagnostic column is refused", (value) => { + value.upserts[0]!.diagnostics[0]!.column = 0; + refresh(value); + }); + rejects("an invalid diagnostic severity is refused", (value) => { + value.upserts[0]!.diagnostics[0]!.severity = "fatal"; + refresh(value); + }); + rejects("an invalid diagnostic code is refused", (value) => { + value.upserts[0]!.diagnostics[0]!.code = ""; + refresh(value); + }); + rejects("an invalid diagnostic message is refused", (value) => { + value.upserts[0]!.diagnostics[0]!.message = ""; + refresh(value); + }); + rejects("a duplicate coverage family is refused", (value) => { + value.upserts[0]!.coverage.push({ ...value.upserts[0]!.coverage[0]! }); + refresh(value); + }); + rejects("an incomplete coverage matrix is refused", (value) => { + value.upserts[0]!.coverage.pop(); + refresh(value); + }); + rejects("a malformed unresolved boundary is refused", (value) => { + value.upserts[0]!.unresolved[0]!.candidates = ["same", "same"]; + refresh(value); + }); + rejects("a foreign unresolved candidate identity is refused", (value) => { + value.upserts[0]!.unresolved[0]!.candidates = ["foreign|node"]; + refresh(value); + }); + rejects("an invalid evidence range is refused", (value) => { + value.upserts[0]!.nodes[0]!.evidence!.startLine = 0; + refresh(value); + }); + rejects("a reversed evidence range is refused", (value) => { + value.upserts[0]!.nodes[0]!.evidence!.startColumn = 5; + value.upserts[0]!.nodes[0]!.evidence!.endColumn = 4; + refresh(value); + }); + const corruptStates: Array<[string, (value: IRustGraphCacheState) => void]> = [ + ["persisted producer identity", (value) => (value.producerCommit = "wrong")], + ["persisted raw shard payload", (value) => (value.rawShards[0]!.digest = digest("bad"))], + [ + "persisted raw shard identity", + (value) => { + value.rawShards[0]!.key = "wrong"; + value.rawShards[0]!.digest = rawShardDigest(value.rawShards[0]!); + }, + ], + ["persisted producer checkpoint", (value) => value.checkpoint.manifest.pop()], + [ + "persisted normalized producer attribution", + (value) => { + const hello = value.frames[0]; + if (hello?.type === "hello") hello.producer = "forged-producer"; + }, + ], + [ + "persisted graph checkpoint", + (value) => ((value.frames[1]! as { type: string }).type = "hello"), + ], + [ + "persisted graph generation", + (value) => { + const generation = digest("graph-only-generation"); + (value.frames[1]! as { generation: string }).generation = generation; + (value.frames.at(-1)! as { generation: string }).generation = generation; + }, + ], + ]; + for (const [label, mutate] of corruptStates) { + TestValidator.error(`${label} corruption is refused`, () => { + const candidate = structuredClone(state); + mutate(candidate); + new RustGraphSnapshotAdapter(root, COMMIT, candidate); + }); + } +} + +function assertDeltaDeletionAndCrossShardRefusals(root: string): void { + const universe = digest("multi-shard-universe"); + const first = rawShard("first", universe, "src/first.rs", "-first"); + const second = rawShard("second", universe, "src/second.rs", "-second"); + const full = snapshot({ universe, upserts: [first, second] }); + const adapter = new RustGraphSnapshotAdapter(root, COMMIT); + const prepared = adapter.prepare(full); + if (!prepared.changed) throw new Error("multi-shard Rust fixture did not change"); + prepared.commit(adapter.store.apply(prepared.frames)); + const deleted = snapshot({ + base: full, + universe, + upserts: [], + deletes: [second.key], + sequence: 2, + }); + const deletion = adapter.prepare(deleted); + if (!deletion.changed) throw new Error("Rust shard deletion did not change"); + const afterDeletion = deletion.commit(adapter.store.apply(deletion.frames)); + TestValidator.equals( + "an incremental Rust generation deletes exactly its named shard", + afterDeletion.nodes.map((node) => node.name), + ["dependency", "first"], + ); + + const initialized = (): RustGraphSnapshotAdapter => { + const value = new RustGraphSnapshotAdapter(root, COMMIT); + const initial = value.prepare(structuredClone(full)); + if (!initial.changed) throw new Error("multi-shard Rust fixture did not change"); + initial.commit(value.store.apply(initial.frames)); + return value; + }; + TestValidator.error("one delta cannot delete and upsert the same shard", () => { + initialized().prepare( + snapshot({ + base: full, + universe, + upserts: [structuredClone(first)], + deletes: [first.key], + sequence: 2, + }), + ); + }); + TestValidator.error("an edge endpoint absent from every raw shard is refused", () => { + const bad = structuredClone(full); + bad.upserts[0]!.edges[0]!.to = "rust-hir-v1|absent"; + refresh(bad); + new RustGraphSnapshotAdapter(root, COMMIT).prepare(bad); + }); + TestValidator.error("two raw shards cannot disagree about coverage", () => { + const badSecond = structuredClone(second); + badSecond.coverage[0]!.state = "complete"; + badSecond.digest = rawShardDigest(badSecond); + new RustGraphSnapshotAdapter(root, COMMIT).prepare( + snapshot({ universe, upserts: [structuredClone(first), badSecond] }), + ); + }); + TestValidator.error("one native node identity cannot describe two declarations", () => { + const badSecond = structuredClone(second); + badSecond.nodes[0]!.id = first.nodes[0]!.id; + badSecond.digest = rawShardDigest(badSecond); + new RustGraphSnapshotAdapter(root, COMMIT).prepare( + snapshot({ universe, upserts: [structuredClone(first), badSecond] }), + ); + }); + TestValidator.error("external node facts must agree across raw shards", () => { + const badSecond = structuredClone(second); + badSecond.nodes[1]!.signature = "fn(u8)"; + badSecond.digest = rawShardDigest(badSecond); + new RustGraphSnapshotAdapter(root, COMMIT).prepare( + snapshot({ universe, upserts: [structuredClone(first), badSecond] }), + ); + }); +} + +function assertCacheFallback(root: string, state: IRustGraphCacheState): void { + const cacheRoot = GraphPaths.createTempDirectory("samchon-graph-rust-cache-"); + const props = { root, producerCommit: COMMIT, cacheRoot }; + const sequence = (state.frames.at(-1) as { sequence: number }).sequence; + TestValidator.equals("an absent Rust checkpoint cache is empty", RustGraphCache.load(props), undefined); + TestValidator.error("invalid persisted coordinates are refused", () => + RustGraphCache.save(props, 0, state.checkpoint.generation, state), + ); + RustGraphCache.save(props, sequence, state.checkpoint.generation, state); + const directory = findGenerationDirectory(cacheRoot); + const invalidCacheFiles = [ + `999999999999999999999999-${state.checkpoint.generation}.json`, + `${String(sequence + 102)}-${state.checkpoint.generation}.json`, + `${String(sequence + 101)}-${state.checkpoint.generation}.json`, + ]; + fs.writeFileSync(path.join(directory, invalidCacheFiles[0]!), "{}"); + fs.writeFileSync(path.join(directory, invalidCacheFiles[1]!), ""); + fs.writeFileSync( + path.join(directory, invalidCacheFiles[2]!), + JSON.stringify({ ...state, frames: [null] }), + ); + TestValidator.equals( + "a saved Rust checkpoint skips invalid coordinates, empty files, and malformed commit frames", + [ + RustGraphCache.load(props)?.checkpoint.generation, + RustGraphCache.load(props, () => false), + ], + [state.checkpoint.generation, undefined], + ); + for (const file of invalidCacheFiles) fs.rmSync(path.join(directory, file)); + RustGraphCache.save(props, sequence, state.checkpoint.generation, state); + fs.writeFileSync( + path.join(directory, `${String(sequence + 1)}-${digest("second")}.json`), + "{", + ); + TestValidator.equals( + "a torn newest generation falls back to the prior immutable checkpoint", + RustGraphCache.load(props)?.checkpoint.generation, + state.checkpoint.generation, + ); + fs.rmSync(path.join(directory, `${String(sequence + 1)}-${digest("second")}.json`)); + const second = cacheStateAtSequence(state, sequence + 1); + const third = cacheStateAtSequence(state, sequence + 2); + RustGraphCache.save(props, sequence + 1, state.checkpoint.generation, second); + RustGraphCache.save(props, sequence + 2, state.checkpoint.generation, third); + TestValidator.equals( + "the immutable cache retains only its two newest validated generations", + fs + .readdirSync(directory) + .filter((file) => file.endsWith(".json")) + .sort(), + [ + `${String(sequence + 1)}-${state.checkpoint.generation}.json`, + `${String(sequence + 2)}-${state.checkpoint.generation}.json`, + ], + ); + const mutableFs = fs as typeof fs & { renameSync: typeof fs.renameSync }; + const renameSync = mutableFs.renameSync; + try { + mutableFs.renameSync = (temporary, file) => { + fs.copyFileSync(temporary, file); + throw new Error("fixture concurrent cache winner"); + }; + const winner = cacheStateAtSequence(state, sequence + 3); + RustGraphCache.save(props, sequence + 3, state.checkpoint.generation, winner); + mutableFs.renameSync = () => { + throw new Error("fixture cache rename failure"); + }; + TestValidator.error("a cache rename failure without a winner is surfaced", () => { + const losing = cacheStateAtSequence(state, sequence + 4); + RustGraphCache.save(props, sequence + 4, state.checkpoint.generation, losing); + }); + } finally { + mutableFs.renameSync = renameSync; + } + fs.writeFileSync( + path.join(directory, `.1-${String(sequence)}-${digest("temporary")}.tmp`), + "torn", + ); + fs.writeFileSync(path.join(directory, "unrelated.txt"), "keep"); + RustGraphCache.clear(props); + TestValidator.equals( + "cache cleanup removes owned generations and temporaries only", + fs.readdirSync(directory), + ["unrelated.txt"], + ); + RustGraphCache.clear({ ...props, root: path.join(root, "absent") }); + assertDefaultCacheRootBranches(root); +} + +function cacheStateAtSequence( + state: IRustGraphCacheState, + sequence: number, +): IRustGraphCacheState { + const output = structuredClone(state); + for (const frame of output.frames) { + if (frame.type === "begin" || frame.type === "commit") frame.sequence = sequence; + } + return output; +} + +function assertDefaultCacheRootBranches(root: string): void { + const names = ["SAMCHON_GRAPH_CACHE_DIR", "LOCALAPPDATA", "XDG_CACHE_HOME"] as const; + const prior = new Map(names.map((name) => [name, process.env[name]])); + try { + delete process.env.SAMCHON_GRAPH_CACHE_DIR; + process.env.LOCALAPPDATA = GraphPaths.createTempDirectory("samchon-rust-local-cache-"); + delete process.env.XDG_CACHE_HOME; + RustGraphCache.clear({ root, producerCommit: COMMIT }); + + delete process.env.LOCALAPPDATA; + process.env.XDG_CACHE_HOME = GraphPaths.createTempDirectory("samchon-rust-xdg-cache-"); + RustGraphCache.clear({ root, producerCommit: COMMIT }); + + process.env.XDG_CACHE_HOME = "relative-cache"; + RustGraphCache.clear({ root, producerCommit: COMMIT }); + } finally { + for (const name of names) { + const value = prior.get(name); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } +} + +interface ISnapshotOptions { + base?: IRustGraphSnapshot; + nodeName?: string; + sequence?: number; + universe?: string; + upserts?: IRustGraphShard[]; + deletes?: string[]; +} + +function snapshot(options: ISnapshotOptions = {}): IRustGraphSnapshot { + const universe = options.universe ?? options.base?.universe.digest ?? digest("universe-1"); + const shard = rawShard(options.nodeName ?? "answer", universe); + const rawShards = options.upserts ?? [shard]; + const next = new Map(); + if (options.base !== undefined) { + for (const prior of options.base.upserts) next.set(prior.key, structuredClone(prior)); + } + for (const deleted of options.deletes ?? []) next.delete(deleted); + for (const upsert of rawShards) next.set(upsert.key, structuredClone(upsert)); + const manifest = [...next.values()] + .sort((left, right) => compare(left.key, right.key)) + .map((entry) => ({ key: entry.key, digest: entry.digest })); + const generation = digest({ universe, manifest }); + return { + protocolVersion: 1, + schemaVersion: 1, + producer: { + name: "samchon-rust-analyzer", + version: "1.95.0", + commit: COMMIT, + }, + universe: { + digest: universe, + target: "app", + workspaceRoots: ["."], + toolchains: ["stable"], + configurations: [ + "rustc-version=rustc 1.95.0 (fixture)\ncommit-hash: fixture\nhost: fixture", + ], + }, + sequence: options.sequence ?? 1, + generation, + baseGeneration: options.base?.generation ?? null, + upserts: rawShards.map((entry) => structuredClone(entry)), + deletes: [...(options.deletes ?? [])].sort(compare), + manifest, + phases: { + semanticMillis: 1, + shardMillis: 2, + encodeMillis: 3, + totalMillis: 6, + cacheHit: rawShards.length === 0, + }, + }; +} + +function rawShard( + nodeName: string, + _universe: string, + source = "src/lib.rs", + suffix = "", +): IRustGraphShard { + const evidence = { + file: source, + startLine: 1, + startColumn: 1, + endLine: 1, + endColumn: 10, + }; + const shard: IRustGraphShard = { + key: `app\0${source}`, + source, + checkerDigest: + source === "src/lib.rs" + ? sourceDigest("pub fn answer() -> u8 { 42 }\n") + : sourceDigest(`fixture source: ${source}\n`), + interfaceFingerprint: digest(`interface-${nodeName}`), + digest: "", + nodes: [ + { + id: `rust-hir-v1|answer${suffix}`, + kind: "function", + name: nodeName, + qualifiedName: `fixture::${nodeName}`, + file: source, + external: false, + exported: true, + signature: "fn() -> u8", + evidence, + }, + { + id: "rust-hir-v1|dependency", + kind: "function", + name: "dependency", + qualifiedName: null, + file: "bundled:///rust/dependencies", + external: true, + exported: false, + signature: null, + evidence: null, + }, + ], + edges: [ + { + from: `rust-hir-v1|answer${suffix}`, + to: "rust-hir-v1|dependency", + kind: "calls", + evidence, + }, + ], + diagnostics: [ + { + file: source, + line: 1, + column: null, + code: "fixture", + message: "fixture warning", + severity: "warning", + }, + ], + coverage: GRAPH_EDGE_KINDS.map((family) => ({ + family, + state: family === "renders" ? "unsupported" : "partial", + })), + unresolved: GRAPH_EDGE_KINDS.filter((family) => family !== "renders").map( + (family) => ({ + family, + evidence, + reason: "provider-gap", + candidates: ["rust-hir-v1|dependency"], + }), + ), + }; + shard.digest = rawShardDigest(shard); + return shard; +} + +function refresh(value: IRustGraphSnapshot): void { + for (const shard of value.upserts) shard.digest = rawShardDigest(shard); + value.manifest = value.upserts + .map((shard) => ({ key: shard.key, digest: shard.digest })) + .sort((left, right) => compare(left.key, right.key)); + value.generation = digest({ universe: value.universe.digest, manifest: value.manifest }); +} + +function rawShardDigest(shard: IRustGraphShard): string { + return digest({ + key: shard.key, + source: shard.source, + checkerDigest: shard.checkerDigest, + interfaceFingerprint: shard.interfaceFingerprint, + nodes: shard.nodes, + edges: shard.edges, + diagnostics: shard.diagnostics, + coverage: shard.coverage, + unresolved: shard.unresolved, + }); +} + +function digest(value: unknown): string { + return createHash("sha256").update(canonical(value)).digest("hex"); +} + +function sourceDigest(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function canonical(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + const object = value as Record; + return `{${Object.keys(object) + .sort(compare) + .map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`) + .join(",")}}`; +} + +function compare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function findGenerationDirectory(cacheRoot: string): string { + const rust = path.join(cacheRoot, "rust", COMMIT); + return path.join(rust, fs.readdirSync(rust)[0]!); +} diff --git a/tests/test-graph/src/features/test_rust_scip_provider_preserves_cargo_and_toolchain_boundaries.ts b/tests/test-graph/src/features/test_rust_scip_provider_preserves_cargo_and_toolchain_boundaries.ts index ca5d32e2..b1420bfe 100644 --- a/tests/test-graph/src/features/test_rust_scip_provider_preserves_cargo_and_toolchain_boundaries.ts +++ b/tests/test-graph/src/features/test_rust_scip_provider_preserves_cargo_and_toolchain_boundaries.ts @@ -234,7 +234,7 @@ async function assertProviderSnapshot(root: string): Promise { [ "rust-analyzer-scip", "semantic-index", - ["contains", "references", "type_ref"], + ["contains", "references"], "rustc=fixture rustc; cargo=fixture cargo", false, "", diff --git a/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts b/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts index e4fb8551..0a235f4f 100644 --- a/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts +++ b/tests/test-graph/src/features/test_shipped_source_does_not_leak_benchmark_corpus_names.ts @@ -21,15 +21,32 @@ const CORPUS_NAMES = [ "darthttp", ] as const; +/** + * A corpus name in product source is how a fixture becomes a specification: + * the next reader treats the named project as the thing the code must satisfy + * and special-cases it. The Go sidecar carried exactly that shape until this + * campaign — two comments explaining real defects by naming the corpus that + * exhibited them — and both were rewritten to describe the condition instead. + * + * Scope is `packages/graph/src` and `packages/graph/sidecars`. The README is + * outside it on purpose: its benchmark tables name every corpus as published + * measurement evidence. + */ export const test_shipped_source_does_not_leak_benchmark_corpus_names = () => { - const sourceRoot = path.join(GraphPaths.graphPackageRoot, "src"); + const roots = [ + path.join(GraphPaths.graphPackageRoot, "src"), + path.join(GraphPaths.graphPackageRoot, "sidecars"), + ]; const leaked: string[] = []; - for (const file of walk(sourceRoot)) { - const source = fs.readFileSync(file, "utf8").toLowerCase(); - for (const name of CORPUS_NAMES) - if (new RegExp(`\\b${name}\\b`, "u").test(source)) - leaked.push(`${path.relative(sourceRoot, file).replaceAll("\\", "/")}: ${name}`); - } + for (const root of roots) + for (const file of walk(root)) { + const source = fs.readFileSync(file, "utf8").toLowerCase(); + for (const name of CORPUS_NAMES) + if (new RegExp(`\\b${name}\\b`, "u").test(source)) + leaked.push( + `${path.relative(GraphPaths.graphPackageRoot, file).replaceAll("\\", "/")}: ${name}`, + ); + } TestValidator.equals( "the published source carries no benchmark repository names", leaked, @@ -44,5 +61,11 @@ function walk(directory: string): string[] { const file = path.join(directory, entry.name); return entry.isDirectory() ? walk(file) : [file]; }) - .filter((file) => /\.(?:ts|js|mjs|cjs|json|html)$/u.test(file)); + .filter((file) => + // Every extension `copy-sidecars.mjs` ships, plus the package's own + // source. `.java` was missing while `sidecars/gradle` shipped a `.java` + // file, so the one shipped language most likely to name a JVM corpus was + // the one language this never read. + /\.(?:ts|js|mjs|cjs|json|html|go|java|lua|mod|sum)$/u.test(file), + ); } diff --git a/tests/test-graph/src/features/test_standard_providers_execute_their_exact_contracts.ts b/tests/test-graph/src/features/test_standard_providers_execute_their_exact_contracts.ts index 68cee4e3..6eda92e0 100644 --- a/tests/test-graph/src/features/test_standard_providers_execute_their_exact_contracts.ts +++ b/tests/test-graph/src/features/test_standard_providers_execute_their_exact_contracts.ts @@ -5,13 +5,17 @@ import { type GraphEdgeKind, type IBulkGraphSession, type IGraphProvider, + RUST_GRAPH_PRODUCER_COMMIT, + CPP_CLANG_PRODUCER_COMMIT, + cppGraphProvider, goGraphProvider, luaGraphProvider, - rustScipProvider, + rustGraphProvider, standardScipProviders, standardSidecarProviders, } from "@samchon/graph"; import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; @@ -923,8 +927,11 @@ function assertFixtureRegistryCoverage(): void { ttscGraphProvider, goGraphProvider, luaGraphProvider, - rustScipProvider, - ...standardScipProviders, + rustGraphProvider, + cppGraphProvider, + ...standardScipProviders.filter( + (provider) => provider.name !== "scip-clang", + ), ...standardSidecarProviders, ] .map((provider) => provider.name) @@ -1011,6 +1018,7 @@ async function assertHeuristicTwinFails( provider: IGraphProvider, command: IGraphProvider.ICommand, root: string, + relationship: GraphEdgeKind = "references", ): Promise { const prior = process.env.SAMCHON_GRAPH_FIXTURE_MODE; process.env.SAMCHON_GRAPH_FIXTURE_MODE = "heuristic"; @@ -1025,7 +1033,7 @@ async function assertHeuristicTwinFails( const refreshed = await session.refresh(); const failures = Conformance.check( refreshed.snapshot, - expectationsForProvider(root, provider), + expectationsForProvider(root, provider, relationship), ).failures; TestValidator.predicate( `${provider.name} rejects only the common comment-only semantic negative twin`, @@ -1113,8 +1121,9 @@ function expectationsOf( function expectationsForProvider( root: string, provider: IGraphProvider, + relationship: GraphEdgeKind = "references", ): readonly Conformance.IExpectation[] { - return expectationsOf(root, provider.languages).filter( + return expectationsOf(root, provider.languages, relationship).filter( (expectation) => !("edge" in expectation) || provider.facts.includes(expectation.edge.kind), @@ -1225,6 +1234,15 @@ async function assertRemainingRegisteredFixtures(root: string): Promise { await assertRegisteredFixture(goGraphProvider, goCommand, root); await assertHeuristicTwinFails(goGraphProvider, goCommand, root); + const cppCommand: IGraphProvider.ICommand = { + command: process.execPath, + args: [ + GraphPaths.fakeCppGraphServer, + `--commit=${CPP_CLANG_PRODUCER_COMMIT}`, + ], + }; + await assertRegisteredFixture(cppGraphProvider, cppCommand, root, "calls"); + // Lua's producer is the language server itself, driven through its `--doc` // export with our exporter injected, so the fixture stands in for the server // rather than for a binary of ours. `prepare` writes the config that carries @@ -1238,23 +1256,26 @@ async function assertRemainingRegisteredFixtures(root: string): Promise { }; await assertRegisteredFixture(luaGraphProvider, luaCommand, root); - // The arguments `resolveRustScipCommand` puts in front of the session's own, - // not an invocation that skips them. A synthetic command without them opens - // the same session against a producer that was never asked the way the - // provider asks it, which is how a wrong subcommand would go unnoticed here - // and be found only by a real lane. + // The HIR fixture speaks the same resident snapshot protocol as the pinned + // fork and carries the shared positive/negative semantic corpus. const rustCommand: IGraphProvider.ICommand = { command: process.execPath, args: [ - GraphPaths.fakeStandardProvider, - "--producer=rust-analyzer", - "scip", - ".", - "--exclude-vendored-libraries", + GraphPaths.fakeRustGraphServer, + `--commit=${RUST_GRAPH_PRODUCER_COMMIT}`, + "--conformance", ], }; - await assertRegisteredFixture(rustScipProvider, rustCommand, root); - await assertHeuristicTwinFails(rustScipProvider, rustCommand, root); + await assertRegisteredFixture(rustGraphProvider, rustCommand, root, "calls"); + await assertHeuristicTwinFails( + rustGraphProvider, + { + ...rustCommand, + args: [...rustCommand.args, "--conformance-heuristic"], + }, + root, + "calls", + ); } async function assertRegisteredFixture( @@ -1280,6 +1301,14 @@ async function assertRegisteredFixture( "rustc=rustc v1.0.0; cargo=cargo v1.0.0", ); } + if (provider.name === "samchon-rust-analyzer-hir") { + const source = path.join(root, "src/lib.rs"); + TestValidator.equals( + "the Rust HIR source digest binds analyzer facts to the coordinator's disk generation", + refreshed.snapshot.sources.get(source)?.diskDigest, + createHash("sha256").update(fs.readFileSync(source)).digest("hex"), + ); + } // Compared rather than reduced to a predicate: a conformance report names // exactly which invariant a provider broke, and folding it into a boolean // throws that away at the one moment it is worth having. diff --git a/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts b/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts index e8b1679a..708958c7 100644 --- a/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts +++ b/tests/test-graph/src/features/test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots.ts @@ -9,8 +9,17 @@ import { TtscGraphClient } from "../../../../packages/graph/src/provider/ttscgra import { resolveTtscGraphCommand } from "../../../../packages/graph/src/provider/ttscgraph/resolveTtscGraphCommand"; import { ttscGraphProvider } from "../../../../packages/graph/src/provider/ttscgraph/ttscGraphProvider"; import { ISamchonGraphDump } from "../../../../packages/graph/src/structures"; +import { GRAPH_EDGE_KINDS } from "../../../../packages/graph/src/typings/GRAPH_EDGE_KINDS"; import { GraphPaths } from "../internal/GraphPaths"; +/** + * The reference TypeScript route must retain compiler evidence while applying + * native shard deltas as one common-protocol generation. + * + * 1. Publish a complete compiler-backed generation and reuse it unchanged. + * 2. Replace only the changed source-owned shards on an incremental response. + * 3. Verify normalized facts, provenance, coverage, reuse, and failure atomicity. + */ export const test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapshots = async () => { const root = GraphPaths.createTempDirectory( @@ -49,8 +58,16 @@ export const test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapsho }); const initial = await client.refresh(); - TestValidator.equals("the first full dump starts generation one", initial.generation, 1); - TestValidator.equals("the compiler language is added losslessly", initial.snapshot.nodes[0]?.language, "typescript"); + TestValidator.equals( + "the first native transaction starts generation one", + initial.generation, + 1, + ); + TestValidator.equals( + "the compiler language is added losslessly", + initial.snapshot.nodes.find((node) => node.name === "first")?.language, + "typescript", + ); TestValidator.equals("the module export surface folds onto its file", initial.snapshot.edges[0]?.from, "src/index.ts"); TestValidator.equals("edge evidence keeps the module source file", initial.snapshot.edges[0]?.evidence?.file, "src/index.ts"); TestValidator.predicate( @@ -81,9 +98,12 @@ export const test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapsho ); TestValidator.predicate( "compiler flags and decorator literals survive adaptation", - initial.snapshot.nodes[0]?.ignored === true && - initial.snapshot.nodes[0]?.closure === true && - initial.snapshot.nodes[0]?.decorators?.[0]?.arguments[0]?.literal === 1, + initial.snapshot.nodes.find((node) => node.name === "first")?.ignored === + true && + initial.snapshot.nodes.find((node) => node.name === "first") + ?.closure === true && + initial.snapshot.nodes.find((node) => node.name === "first") + ?.decorators?.[0]?.arguments[0]?.literal === 1, ); TestValidator.equals( "the snapshot names its files by the digest the compiler read, not by their text", @@ -108,6 +128,41 @@ export const test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapsho initial.snapshot.provenance.schemaVersion, 6, ); + TestValidator.equals( + "the reference provider publishes one exhaustive protocol coverage matrix", + [ + initial.snapshot.protocol?.sequence, + initial.snapshot.protocol?.targets, + initial.snapshot.coverage?.length, + initial.snapshot.coverage?.filter((row) => row.state === "complete") + .length, + initial.snapshot.coverage?.filter((row) => row.state === "partial") + .length, + initial.snapshot.coverage?.filter( + (row) => row.state === "unsupported", + ).length, + initial.snapshot.unresolved?.length, + initial.snapshot.unresolved?.every( + (site) => site.reason === "provider-gap", + ), + ], + [ + 1, + ["tsconfig.json"], + GRAPH_EDGE_KINDS.length, + 0, + ttscGraphProvider.facts.length, + GRAPH_EDGE_KINDS.length - ttscGraphProvider.facts.length, + ttscGraphProvider.facts.length, + true, + ], + ); + const initialShards = new Map( + initial.snapshot.protocol?.shards.map((shard) => [ + shard.key, + shard.digest, + ]), + ); TestValidator.equals( "the first snapshot reports the compiler's own mode, not an inferred one", initial.mode, @@ -143,17 +198,41 @@ export const test_ttscgraph_bulk_provider_reuses_and_atomically_replaces_snapsho const changed = await client.refresh(); TestValidator.predicate( - "a validated full dump atomically replaces the snapshot", + "a validated shard transaction atomically replaces the snapshot", changed.changed && changed.generation === 2 && changed.snapshot !== initial.snapshot && - changed.snapshot.nodes[0]?.name === "second", + changed.snapshot.nodes.some((node) => node.name === "second"), ); TestValidator.equals( "a reused program is reported as incremental because the compiler said so", changed.mode, "incremental", ); + const changedShards = new Map( + changed.snapshot.protocol?.shards.map((shard) => [ + shard.key, + shard.digest, + ]), + ); + TestValidator.equals( + "the incremental generation is based on the prior commit and reuses unaffected file shards", + [ + changed.snapshot.protocol?.baseSequence, + changed.snapshot.protocol?.baseGeneration, + [...changedShards].filter( + ([key, digest]) => initialShards.get(key) === digest, + ).length, + [...changedShards].filter( + ([key, digest]) => + initialShards.has(key) && initialShards.get(key) !== digest, + ).length, + ], + // The edited source itself has a new content-addressed producer key, so + // it appears as delete+upsert. The one same-key digest replacement is + // the unchanged importer whose outgoing export edge was invalidated. + [1, initial.snapshot.protocol?.generation, 6, 1], + ); await rejects(client.refresh(), "serve errors are surfaced"); TestValidator.predicate( "an untrusted child generation preserves the previous trusted snapshot", diff --git a/tests/test-graph/src/features/test_ttscgraph_dump_adapter_rejects_malformed_facts.ts b/tests/test-graph/src/features/test_ttscgraph_dump_adapter_rejects_malformed_facts.ts index 3c952b4c..2031c4a6 100644 --- a/tests/test-graph/src/features/test_ttscgraph_dump_adapter_rejects_malformed_facts.ts +++ b/tests/test-graph/src/features/test_ttscgraph_dump_adapter_rejects_malformed_facts.ts @@ -48,6 +48,7 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { const good = () => ({ project, + tsconfig: "tsconfig.json", provenance: provenance(), diagnostics: [] as unknown[], nodes: [ @@ -104,6 +105,17 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { "a well-formed dump adapts cleanly", adaptTtscGraphDump(good(), project).nodes.length === 2, ); + rejectsWithMessage( + () => + adaptTtscGraphDump( + mutate((d) => { + d.tsconfig = "tsconfig.missing.json"; + }), + project, + ), + "a dump target absent from its build universe", + "dump.tsconfig names an unknown build-universe config", + ); rejectsWithMessage( () => adaptTtscGraphDump( @@ -528,6 +540,35 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { ), "a diagnostic without the diagnostics capability", ); + rejectsWithMessage( + () => + adaptTtscGraphDump( + mutate((d) => { + d.provenance.sources.push({ ...d.provenance.sources[0] }); + }), + project, + ), + "a duplicate source manifest entry", + "duplicate source manifest entry", + ); + rejectsWithMessage( + () => + adaptTtscGraphDump( + mutate((d) => { + d.diagnostics.push({ + file: "src/unloaded.ts", + line: 1, + column: 1, + code: 2322, + category: "error", + message: "unloaded finding", + }); + }), + project, + ), + "a diagnostic outside the source manifest", + "source manifest never loaded", + ); // Identity format and uniqueness. rejects(() => adaptTtscGraphDump(mutate((d) => ((d.nodes[1] as { id: string }).id = "no-hash-here")), project), "a node id that does not encode its file and kind"); @@ -557,6 +598,18 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { // Edge endpoints and uniqueness. rejects(() => adaptTtscGraphDump(mutate((d) => ((d.edges[0] as { from: string }).from = "src/a.ts#ghost:function")), project), "an edge from an unknown endpoint"); + rejectsWithMessage( + () => + adaptTtscGraphDump( + mutate((d) => { + (d.edges[0] as { to: string }).to = + "src/a.ts#src/a.ts:module"; + }), + project, + ), + "an edge to a folded module endpoint", + "unknown or folded to endpoint", + ); rejects(() => adaptTtscGraphDump(mutate((d) => d.edges.push({ ...(d.edges[0] as object) })), project), "a duplicate edge after module folding"); // Evidence spans and decorator literals. @@ -606,6 +659,7 @@ export const test_ttscgraph_dump_adapter_rejects_malformed_facts = async () => { const rich = adaptTtscGraphDump( { project, + tsconfig: "tsconfig.json", provenance: provenance(["src/a.ts", "vendor/dep.ts"]), diagnostics: [], nodes: [ diff --git a/tests/test-graph/src/features/test_ttscgraph_native_delta_bounds_raw_facts_to_dependencies.ts b/tests/test-graph/src/features/test_ttscgraph_native_delta_bounds_raw_facts_to_dependencies.ts new file mode 100644 index 00000000..53e0f00f --- /dev/null +++ b/tests/test-graph/src/features/test_ttscgraph_native_delta_bounds_raw_facts_to_dependencies.ts @@ -0,0 +1,241 @@ +import { TestValidator } from "@nestia/e2e"; +import { createHash } from "node:crypto"; + +import { IBulkGraphSession } from "../../../../packages/graph/src/provider/IBulkGraphSession"; +import { TtscGraphSnapshotStore } from "../../../../packages/graph/src/provider/ttscgraph/TtscGraphSnapshotStore"; +import { GraphPaths } from "../internal/GraphPaths"; + +/** + * A compact atomic manifest is generation-wide, but semantic fact parsing is + * bounded to one changed source and the retained targets its edges require. + */ +export const test_ttscgraph_native_delta_bounds_raw_facts_to_dependencies = + () => { + const root = GraphPaths.createTempDirectory( + "samchon-graph-native-delta-cost-", + ); + const fixture = initialTransaction(root, 32); + const store = new TtscGraphSnapshotStore(root); + const initial = store.prepare(fixture.transaction, { sequence: 1 }); + initial.commit(); + for (const counter of fixture.counters) counter.reads = 0; + + const changedCounter = { reads: 0 }; + const changedShard = sourceShard( + fixture.files[0]!, + "1:source:replacement", + digest("replacement checker text"), + changedCounter, + `${fixture.files[1]}#${fixture.files[1]}:function`, + ); + const changedDigest = digestJson(changedShard); + const manifest = [ + ...fixture.transaction.manifest.filter( + (entry) => entry.key !== fixture.sourceKeys[0], + ), + { key: changedShard.key, digest: changedDigest }, + ].sort((left, right) => compareUtf8(left.key, right.key)); + const transaction = { + ...fixture.transaction, + sequence: 2, + baseSequence: 1, + baseGeneration: fixture.transaction.generation, + generation: digestJson({ + tsconfig: fixture.transaction.tsconfig, + producer: fixture.transaction.producer, + capabilities: fixture.transaction.capabilities, + universe: fixture.transaction.universe, + manifest, + }), + upserts: [{ digest: changedDigest, shard: changedShard }], + deletes: [fixture.sourceKeys[0]], + manifest, + }; + // Signing the changed shard exercises its getter before the measured + // prepare. Only accesses caused by delta validation count below. + changedCounter.reads = 0; + store.prepare(transaction, { + sequence: 2, + previous: { + protocol: { + sequence: 1, + generation: fixture.transaction.generation, + }, + } as unknown as IBulkGraphSession.ISnapshot, + }); + + TestValidator.equals( + "a one-source delta never scans unrelated retained raw facts", + fixture.counters.slice(2).reduce((sum, row) => sum + row.reads, 0), + 0, + ); + TestValidator.predicate( + "a changed cross-shard edge reparses its retained target dependency", + fixture.counters[1]!.reads > 0, + ); + TestValidator.predicate( + "the replacement source is still parsed and validated", + changedCounter.reads > 0, + ); + }; + +function initialTransaction(root: string, size: number): { + transaction: ReturnType; + counters: { reads: number }[]; + files: string[]; + sourceKeys: string[]; +} { + const producer = { + tool: "ttscgraph", + version: "test", + typescript: "5.9.0", + }; + const capabilities = [ + "universe", + "sourceDigests", + "diskDigests", + "diagnostics", + ]; + const files = Array.from( + { length: size }, + (_, index) => `src/f${String(index).padStart(3, "0")}.ts`, + ); + const config = { file: "tsconfig.json", digest: digest("configuration") }; + const universe = { + configs: [config], + roots: files.map((file) => ({ config: config.file, file })), + }; + const counters = files.map(() => ({ reads: 0 })); + const sourceKeys = files.map( + (_, index) => `1:source:${String(index).padStart(3, "0")}`, + ); + const shards = files.map((file, index) => + sourceShard( + file, + sourceKeys[index]!, + digest(`checker:${file}`), + counters[index]!, + index === 0 ? `${files[1]}#${files[1]}:function` : undefined, + ), + ); + shards.push({ + key: "3:config", + config, + nodes: [], + edges: [], + diagnostics: [], + } as ReturnType); + const upserts = shards.map((shard) => ({ + digest: digestJson(shard), + shard, + })); + const manifest = upserts + .map((entry) => ({ key: entry.shard.key, digest: entry.digest })) + .sort((left, right) => compareUtf8(left.key, right.key)); + const transaction = transactionOf({ + root, + producer, + capabilities, + universe, + upserts, + manifest, + }); + return { transaction, counters, files, sourceKeys }; +} + +function transactionOf(input: { + root: string; + producer: Record; + capabilities: string[]; + universe: Record; + upserts: { digest: string; shard: ReturnType }[]; + manifest: { key: string; digest: string }[]; +}) { + const transaction = { + protocolVersion: 1, + schemaVersion: 6, + project: input.root, + tsconfig: "tsconfig.json", + producer: input.producer, + capabilities: input.capabilities, + universe: input.universe, + sequence: 1, + generation: "", + upserts: input.upserts, + deletes: [] as string[], + manifest: input.manifest, + }; + transaction.generation = digestJson({ + tsconfig: transaction.tsconfig, + producer: transaction.producer, + capabilities: transaction.capabilities, + universe: transaction.universe, + manifest: transaction.manifest, + }); + return transaction; +} + +function sourceShard( + file: string, + key: string, + checkerDigest: string, + counter: { reads: number }, + target?: string, +) { + const id = `${file}#${file}:function`; + const node = { + get id(): string { + counter.reads += 1; + return id; + }, + kind: "function", + name: file, + file, + external: false, + }; + return { + key, + source: { + file, + checkerDigest, + diskDigest: digest(`disk:${file}`), + }, + nodes: [node], + edges: + target === undefined + ? [] + : [ + { + from: id, + to: target, + kind: "calls", + evidence: { + startLine: 1, + startCol: 1, + endLine: 1, + endCol: 2, + }, + }, + ], + diagnostics: [], + }; +} + +function digest(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +function digestJson(value: unknown): string { + return digest( + JSON.stringify(value).replace(/[<>&\u2028\u2029]/gu, (character) => { + if (character === "<") return "\\u003c"; + if (character === ">") return "\\u003e"; + if (character === "&") return "\\u0026"; + return character === "\u2028" ? "\\u2028" : "\\u2029"; + }), + ); +} + +function compareUtf8(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); +} diff --git a/tests/test-graph/src/features/test_ttscgraph_native_requests_recover_from_stalls.ts b/tests/test-graph/src/features/test_ttscgraph_native_requests_recover_from_stalls.ts index 3da9073c..e407dafa 100644 --- a/tests/test-graph/src/features/test_ttscgraph_native_requests_recover_from_stalls.ts +++ b/tests/test-graph/src/features/test_ttscgraph_native_requests_recover_from_stalls.ts @@ -102,7 +102,7 @@ export const test_ttscgraph_native_requests_recover_from_stalls = async () => { const queuedAbortClient = create( queuedAbortRoot, path.join(queuedAbortRoot, "first-child.txt"), - 5_000, + 15_000, queuedAbortLog, ); const active = queuedAbortClient.refresh(); @@ -395,8 +395,11 @@ const delay = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); const waitForFile = async (file: string): Promise => { - const deadline = Date.now() + 5_000; - while (!fs.existsSync(file)) { + // c8 instruments the whole package before this Windows Job child starts. + // This is fixture-readiness time, not the native request deadline asserted + // above, so keep enough headroom for an instrumented cold process launch. + const deadline = Date.now() + 15_000; + while (!hasContents(file)) { if (Date.now() >= deadline) { throw new Error(`timed out waiting for ${file}`); } @@ -404,6 +407,14 @@ const waitForFile = async (file: string): Promise => { } }; +function hasContents(file: string): boolean { + try { + return fs.statSync(file).size > 0; + } catch { + return false; + } +} + const isProcessAlive = (pid: number): boolean => { try { process.kill(pid, 0); diff --git a/tests/test-graph/src/features/test_ttscgraph_native_shard_transactions_fail_atomically.ts b/tests/test-graph/src/features/test_ttscgraph_native_shard_transactions_fail_atomically.ts new file mode 100644 index 00000000..606c6968 --- /dev/null +++ b/tests/test-graph/src/features/test_ttscgraph_native_shard_transactions_fail_atomically.ts @@ -0,0 +1,153 @@ +import { TestValidator } from "@nestia/e2e"; +import fs from "node:fs"; +import path from "node:path"; + +import { TtscGraphClient } from "../../../../packages/graph/src/provider/ttscgraph/TtscGraphClient"; +import { GraphPaths } from "../internal/GraphPaths"; + +/** + * The native shard lane is a separately versioned atomic transaction, so a + * bad shard digest, manifest or base must never become common graph state. + * + * 1. Request independently corrupted initial transactions through the real client. + * 2. Commit one good generation, then corrupt its incremental successor. + * 3. Require no initial publication and exact prior-object retention respectively. + */ +export const test_ttscgraph_native_shard_transactions_fail_atomically = + async () => { + const initialFailures: Record = { + "--native-invalid-digest": "native shard", + "--native-invalid-manifest": "manifest does not cover", + "--native-invalid-base": "initial native transaction", + "--native-invalid-protocol": "native snapshot protocol v2", + "--native-invalid-schema": "unsupported dump schema", + "--native-invalid-sequence-zero": "native sequence must be", + "--native-invalid-sequence-fraction": "native sequence must be", + "--native-invalid-generation-format": + "native transaction generation must be", + "--native-invalid-generation": "native generation", + "--native-invalid-initial-sequence": "initial native transaction", + "--native-invalid-base-sequence-only": + "native base coordinates are incomplete", + "--native-invalid-base-generation-only": + "native base coordinates are incomplete", + "--native-invalid-base-sequence-type": + "native baseSequence must be", + "--native-invalid-key-empty": "native shard key is invalid", + "--native-invalid-key-nul": "native shard key is invalid", + "--native-invalid-reserved-coverage": + "uses reserved normalized namespace", + "--native-invalid-reserved-coverage-alternate": + "uses reserved normalized namespace", + "--native-invalid-two-input-kinds": "owns two input kinds", + "--native-invalid-duplicate-source": "has two shards", + "--native-invalid-duplicate-config": "has two shards", + "--native-invalid-config-facts": "config shard", + "--native-invalid-nonsource-edges": "non-source shard", + "--native-invalid-source-external-node": "misowns node", + "--native-invalid-source-foreign-node": "misowns node", + "--native-invalid-external-local-node": "misowns node", + "--native-invalid-duplicate-node": "has two owners", + "--native-invalid-local-duplicate-node": "has two owners", + "--native-invalid-source-diagnostic": "misowns diagnostic", + "--native-invalid-config-diagnostic": "misowns diagnostic", + "--native-invalid-metadata-diagnostic": "misowns diagnostic", + "--native-invalid-edge-owner": "misowns edge", + "--native-invalid-config-coverage": "do not cover the universe", + "--native-invalid-config-digest": "config shard disagrees", + "--native-invalid-manifest-sort": "not strictly key-sorted", + "--native-invalid-manifest-entry": "manifest disagrees", + "--native-invalid-manifest-digest-format": + "native manifest digest must be", + "--native-invalid-snapshot-string": "native snapshot must be an object", + "--native-invalid-snapshot-null": "native snapshot must be an object", + "--native-invalid-producer-array": "native producer must be an object", + "--native-invalid-capabilities-array": + "native capabilities must be an array", + "--native-invalid-project-string": "native project must be a string", + "--native-invalid-nodes-array": "nodes must be an array", + "--native-invalid-node-boolean": "external must be boolean", + }; + for (const [mode, expected] of Object.entries(initialFailures)) { + const client = create(fixture(), mode); + try { + const error = await rejectionOf(client.refresh()); + TestValidator.predicate( + `${mode} rejects before initial publication: ${errorText(error)}`, + error instanceof Error && + error.message.includes(expected) && + client.current === undefined && + client.generation === 0, + ); + } finally { + await client.close(); + } + } + + const incrementalFailures: Record = { + "--native-invalid-digest-third": "native shard", + "--native-invalid-base-third": "stale base", + "--native-invalid-project-third": "project coordinates", + "--native-invalid-tsconfig-third": "project coordinates", + "--native-invalid-delete-unknown-third": "deletes unknown shard", + "--native-invalid-delete-duplicate-third": "touches shard", + "--native-invalid-upsert-duplicate-third": "touches shard", + "--native-invalid-retained-edge-target-third": + "native edge target is absent", + }; + for (const [mode, expected] of Object.entries(incrementalFailures)) { + const client = create(fixture(), mode); + try { + const initial = await client.refresh(); + const unchanged = await client.refresh(); + TestValidator.predicate( + `${mode} reuses the good base before its corrupt delta`, + unchanged.snapshot === initial.snapshot && !unchanged.changed, + ); + const error = await rejectionOf(client.refresh()); + TestValidator.predicate( + `${mode} retains the exact committed snapshot and generation: ${errorText(error)}`, + error instanceof Error && + error.message.includes(expected) && + client.current === initial.snapshot && + client.generation === 1, + ); + } finally { + await client.close(); + } + } + }; + +function create(root: string, mode: string): TtscGraphClient { + return new TtscGraphClient({ + root, + command: process.execPath, + args: [GraphPaths.fakeTtscGraphServer, mode], + }); +} + +function fixture(): string { + const root = GraphPaths.createTempDirectory("samchon-graph-native-shards-"); + fs.mkdirSync(path.join(root, "src", "core"), { recursive: true }); + fs.writeFileSync(path.join(root, "tsconfig.json"), "{}\n"); + fs.writeFileSync(path.join(root, "src", "index.ts"), "export {};\n"); + fs.writeFileSync( + path.join(root, "src", "core", "order.ts"), + "export function first() {}\n", + ); + fs.writeFileSync(path.join(root, "src", "empty.ts"), "export {};\n"); + return root; +} + +async function rejectionOf(task: Promise): Promise { + try { + await task; + return undefined; + } catch (error) { + return error; + } +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/tests/test-graph/src/features/test_ttscgraph_native_snapshot_accepts_canonical_project_alias.ts b/tests/test-graph/src/features/test_ttscgraph_native_snapshot_accepts_canonical_project_alias.ts new file mode 100644 index 00000000..41c0e4bb --- /dev/null +++ b/tests/test-graph/src/features/test_ttscgraph_native_snapshot_accepts_canonical_project_alias.ts @@ -0,0 +1,52 @@ +import { TestValidator } from "@nestia/e2e"; +import fs from "node:fs"; +import path from "node:path"; + +import { TtscGraphClient } from "../../../../packages/graph/src/provider/ttscgraph/TtscGraphClient"; +import { GraphPaths } from "../internal/GraphPaths"; + +/** + * The producer publishes its canonical physical project base while callers may + * select the same checkout through a symlink or Windows junction. + * + * 1. Create one physical project and address it through a filesystem alias. + * 2. Make the fake producer publish the physical base in its native transaction. + * 3. Require acceptance while preserving caller-root source identities. + */ +export const test_ttscgraph_native_snapshot_accepts_canonical_project_alias = + async () => { + const parent = GraphPaths.createTempDirectory( + "samchon-graph-native-project-alias-", + ); + const physical = path.join(parent, "physical"); + const alias = path.join(parent, "alias"); + fs.mkdirSync(path.join(physical, "src", "core"), { recursive: true }); + fs.writeFileSync(path.join(physical, "tsconfig.json"), "{}\n"); + fs.writeFileSync(path.join(physical, "src", "index.ts"), "export {};\n"); + fs.writeFileSync( + path.join(physical, "src", "core", "order.ts"), + "export function first() {}\n", + ); + fs.writeFileSync(path.join(physical, "src", "empty.ts"), "export {};\n"); + fs.symlinkSync( + physical, + alias, + process.platform === "win32" ? "junction" : "dir", + ); + + const client = new TtscGraphClient({ + root: alias, + command: process.execPath, + args: [GraphPaths.fakeTtscGraphServer, "--canonical-project"], + }); + try { + const snapshot = (await client.refresh()).snapshot; + TestValidator.predicate( + "the physical producer base and caller alias identify one project", + snapshot.nodes.some((node) => node.name === "first") && + snapshot.sources.has(path.join(alias, "src", "core", "order.ts")), + ); + } finally { + await client.close(); + } + }; diff --git a/tests/test-graph/src/features/test_ttscgraph_phase_trace_is_opt_in_and_filters_producer_stderr.ts b/tests/test-graph/src/features/test_ttscgraph_phase_trace_is_opt_in_and_filters_producer_stderr.ts new file mode 100644 index 00000000..ee977751 --- /dev/null +++ b/tests/test-graph/src/features/test_ttscgraph_phase_trace_is_opt_in_and_filters_producer_stderr.ts @@ -0,0 +1,188 @@ +import { TestValidator } from "@nestia/e2e"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { Worker } from "node:worker_threads"; + +import { ttscGraphPhaseTrace } from "../../../../packages/graph/src/provider/ttscgraph/ttscGraphPhaseTrace"; +import { GraphPaths } from "../internal/GraphPaths"; + +/** + * Phase evidence is a benchmark diagnostic, not a second transport surface. + * + * 1. Construct the trace with disabled and enabled isolated environments. + * 2. Emit a consumer phase and fragmented producer stderr containing noise. + * 3. Require stable timings while rejecting arbitrary diagnostics and paths. + */ +export const test_ttscgraph_phase_trace_is_opt_in_and_filters_producer_stderr = + async () => { + TestValidator.equals( + "the phase trace is disabled by default", + ttscGraphPhaseTrace({}, () => undefined), + undefined, + ); + const lines: string[] = []; + const trace = ttscGraphPhaseTrace( + { SAMCHON_GRAPH_TTSC_PHASE_TRACE: "1" }, + (line) => lines.push(line), + )!; + trace.event({ + request: 7, + mode: "incremental", + phase: "native-normalize", + durationMs: 12.3456, + }); + let buffered = trace.forwardProducer( + "", + "private diagnostic C:\\project\\secret.ts\n" + + "@samchon/graph: ttscgraph-phase C:\\project\\spoof.ts\n" + + "@samchon/graph: ttscgraph-", + ); + buffered = trace.forwardProducer( + buffered, + "phase owner=producer request=7 mode=incremental phase=shard-export durationMs=8.250\n", + ); + TestValidator.equals( + "a complete producer line leaves no buffer", + buffered, + "", + ); + TestValidator.equals( + "only payload-free phase rows reach the trace", + lines, + [ + "@samchon/graph: ttscgraph-phase owner=consumer request=7 " + + "mode=incremental phase=native-normalize durationMs=12.346\n", + "@samchon/graph: ttscgraph-phase owner=producer request=7 " + + "mode=incremental phase=shard-export durationMs=8.250\n", + ], + ); + TestValidator.equals( + "a carriage-return producer line is normalized before filtering", + trace.forwardProducer( + "", + "@samchon/graph: ttscgraph-phase owner=producer request=8 " + + "mode=unchanged phase=producer-total durationMs=1.000\r\n", + ), + "", + ); + TestValidator.equals( + "an unterminated producer diagnostic is bounded", + trace.forwardProducer("", "x".repeat(5_000)).length, + 4_096, + ); + const resilient = ttscGraphPhaseTrace( + { SAMCHON_GRAPH_TTSC_PHASE_TRACE: "1" }, + () => { + throw new Error("synthetic trace sink failure"); + }, + )!; + resilient.event({ + request: 9, + mode: "error", + phase: "mcp-ready", + durationMs: 1, + }); + TestValidator.predicate( + "a failed trace sink cannot change provider control flow", + true, + ); + + const root = GraphPaths.createTempDirectory("samchon-graph-phase-trace-"); + fs.mkdirSync(path.join(root, "src", "core"), { recursive: true }); + fs.writeFileSync(path.join(root, "tsconfig.json"), "{}\n"); + fs.writeFileSync(path.join(root, "src", "index.ts"), "export {};\n"); + fs.writeFileSync( + path.join(root, "src", "core", "order.ts"), + "export function first() {}\n", + ); + fs.writeFileSync(path.join(root, "src", "empty.ts"), "export {};\n"); + const clientModule = pathToFileURL( + path.join( + GraphPaths.graphPackageRoot, + "lib", + "provider", + "ttscgraph", + "TtscGraphClient.js", + ), + ).href; + const child = spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + [ + `const { TtscGraphClient } = await import(${JSON.stringify(clientModule)});`, + "const client = new TtscGraphClient({", + ` root: ${JSON.stringify(root)},`, + " command: process.execPath,", + ` args: [${JSON.stringify(GraphPaths.fakeTtscGraphServer)}, "--phase-trace"],`, + "});", + "try { await client.refresh(); } finally { await client.close(); }", + ].join("\n"), + ], + { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + SAMCHON_GRAPH_TTSC_PHASE_TRACE: "1", + }, + windowsHide: true, + }, + ); + TestValidator.predicate( + "the client forwards only exact producer rows beside its consumer phases", + child.status === 0 && + child.signal === null && + child.stderr.includes("owner=producer request=1 mode=initial") && + child.stderr.includes( + "owner=consumer request=1 mode=initial phase=mcp-ready", + ) && + !child.stderr.includes("spoof.ts"), + ); + + const traceModule = pathToFileURL( + path.join( + GraphPaths.graphPackageRoot, + "lib", + "provider", + "ttscgraph", + "ttscGraphPhaseTrace.js", + ), + ).href; + const worker = new Worker( + [ + "(async () => {", + ` const { ttscGraphPhaseTrace } = await import(${JSON.stringify(traceModule)});`, + " const trace = ttscGraphPhaseTrace({ SAMCHON_GRAPH_TTSC_PHASE_TRACE: '1' });", + " trace.event({ request: 11, mode: 'unchanged', phase: 'mcp-ready', durationMs: 2 });", + "})().catch((error) => { throw error; });", + ].join("\n"), + { eval: true, stderr: true }, + ); + worker.stderr.setEncoding("utf8"); + let workerStderr = ""; + worker.stderr.on("data", (chunk: string) => { + workerStderr += chunk; + }); + const exitPromise = new Promise((resolve, reject) => { + worker.once("error", reject); + worker.once("exit", resolve); + }); + const stderrEnd = new Promise((resolve, reject) => { + worker.stderr.once("error", reject); + worker.stderr.once("end", resolve); + }); + const [exit] = await Promise.all([exitPromise, stderrEnd]); + TestValidator.equals( + "a redirected Worker uses the stream writer when stderr has no fd", + [exit, workerStderr], + [ + 0, + "@samchon/graph: ttscgraph-phase owner=consumer request=11 " + + "mode=unchanged phase=mcp-ready durationMs=2.000\n", + ], + ); + }; diff --git a/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts b/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts new file mode 100644 index 00000000..0152f0c2 --- /dev/null +++ b/tests/test-graph/src/features/test_ttscgraph_protocol_adapter_deletes_dependency_shards.ts @@ -0,0 +1,364 @@ +import { TestValidator } from "@nestia/e2e"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import { GraphSnapshotProtocol } from "../../../../packages/graph/src/provider/GraphSnapshotProtocol"; +import { adaptTtscGraphDump } from "../../../../packages/graph/src/provider/ttscgraph/adaptTtscGraphDump"; +import { createTtscGraphProtocolTransaction } from "../../../../packages/graph/src/provider/ttscgraph/createTtscGraphProtocolTransaction"; +import { TtscGraphClient } from "../../../../packages/graph/src/provider/ttscgraph/TtscGraphClient"; +import { GraphPaths } from "../internal/GraphPaths"; + +const sha256 = (text: string): string => + createHash("sha256").update(text).digest("hex"); + +const compareUtf8 = (left: string, right: string): number => + Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); + +/** + * The TypeScript reference adapter keeps dependency churn inside the protocol: + * a dependency that leaves the compiler manifest becomes an explicit shard + * deletion, while a global compiler finding belongs to the target metadata + * shard rather than to an arbitrary source. + */ +export const test_ttscgraph_protocol_adapter_deletes_dependency_shards = + async () => { + await assertNativeProducerDeltas(); + const root = GraphPaths.createTempDirectory( + "samchon-graph-ttscgraph-protocol-", + ); + const store = new GraphSnapshotProtocol.Store(root); + const initialFrames = createTtscGraphProtocolTransaction( + adaptTtscGraphDump(dump(root, true, false), root), + { root, sequence: 1 }, + ); + const initial = store.apply(initialFrames); + const changedFrames = createTtscGraphProtocolTransaction( + adaptTtscGraphDump(dump(root, false, true), root), + { root, sequence: 2, previous: initial }, + ); + TestValidator.equals( + "a removed dependency is carried as one explicit shard deletion", + changedFrames.filter((frame) => frame.type === "deleteShard").length, + 1, + ); + + const changed = store.apply(changedFrames); + TestValidator.equals( + "the delta removes the dependency and retains a global diagnostic", + [ + changed.protocol?.baseSequence, + changed.nodes.some((node) => node.name === "Dependency"), + changed.sources.has(path.join(root, "vendor", "dependency.d.ts")), + changed.diagnostics, + ], + [ + 1, + false, + false, + [ + { + file: "", + line: 0, + column: 0, + code: 9999, + message: "synthetic global finding", + severity: "warning", + }, + ], + ], + ); + }; + +async function assertNativeProducerDeltas(): Promise { + const root = GraphPaths.createTempDirectory( + "samchon-graph-ttscgraph-native-delta-", + ); + fs.mkdirSync(path.join(root, "src", "core"), { recursive: true }); + fs.writeFileSync(path.join(root, "tsconfig.json"), "{}\n"); + fs.writeFileSync(path.join(root, "src", "index.ts"), "export {};\n"); + fs.writeFileSync( + path.join(root, "src", "core", "order.ts"), + "export function first() {}\n", + ); + fs.writeFileSync(path.join(root, "src", "empty.ts"), "export {};\n"); + fs.writeFileSync(path.join(root, "src", "a&b.ts"), "export {};\n"); + + const bodyLog = path.join(root, "body-native.ndjson"); + const body = new TtscGraphClient({ + root, + command: process.execPath, + args: [ + GraphPaths.fakeTtscGraphServer, + "--native-coordinate-escape", + `--native-log=${bodyLog}`, + ], + }); + try { + const initial = await body.refresh(); + await body.refresh(); + const changed = await body.refresh(); + const [coldTransaction, bodyTransaction] = nativeTransactions(bodyLog); + const coldSource = coldTransaction!.upserts.find((entry) => + entry.shard.source?.file === "src/core/order.ts", + )!; + const coldCoordinates = JSON.parse( + coldSource.shard.key.slice("1:source:".length), + ) as unknown[]; + const normalizedUniverse = normalizeProducerUniverse( + coldTransaction!.universe, + ); + TestValidator.equals( + "the fake producer publishes ttsc's normalized universe order", + coldTransaction!.universe, + normalizedUniverse, + ); + TestValidator.equals( + "the fake producer uses ttsc's exact native shard identity coordinates", + coldCoordinates.slice(0, 6), + [ + 1, + coldTransaction!.producer.tool, + coldTransaction!.producer.version, + coldTransaction!.producer.typescript, + coldTransaction!.tsconfig, + sha256(goJson(normalizedUniverse)), + ], + ); + const escapedSource = coldTransaction!.upserts.find( + (entry) => entry.shard.source?.file === "src/a&b.ts", + )!; + const escapedCoordinates = escapedSource.shard.key.slice( + "1:source:".length, + ); + TestValidator.predicate( + "native shard coordinates use Go's HTML-sensitive JSON escaping", + escapedCoordinates.includes('"src/a\\u0026b.ts"') && + !escapedCoordinates.includes("src/a&b.ts") && + (JSON.parse(escapedCoordinates) as unknown[])[6] === "src/a&b.ts", + ); + const oldSourceKey = coldTransaction!.manifest.find((entry) => + entry.key.includes('"src/core/order.ts"'), + )!.key; + const newSource = bodyTransaction!.upserts.find((entry) => + entry.shard.source?.file === "src/core/order.ts", + ); + TestValidator.predicate( + "a real-client body delta deletes the old content-addressed source key", + bodyTransaction!.deletes.includes(oldSourceKey) && + newSource !== undefined && + newSource.shard.key !== oldSourceKey && + newSource.shard.key.startsWith("1:source:"), + ); + TestValidator.predicate( + "the committed client generation contains only the replacement fact", + initial.snapshot.nodes.some((node) => node.name === "first") && + changed.snapshot.nodes.some((node) => node.name === "second") && + !changed.snapshot.nodes.some((node) => node.name === "first"), + ); + } finally { + await body.close(); + } + + const reloadLog = path.join(root, "reload-native.ndjson"); + const reload = new TtscGraphClient({ + root, + command: process.execPath, + args: [ + GraphPaths.fakeTtscGraphServer, + "--universe-reload", + `--native-log=${reloadLog}`, + ], + }); + try { + await reload.refresh(); + await reload.refresh(); + await reload.refresh(); + const [coldTransaction, reloadTransaction] = + nativeTransactions(reloadLog); + const oldKeys = new Set( + coldTransaction!.manifest.map((entry) => entry.key), + ); + const newKeys = new Set( + reloadTransaction!.manifest.map((entry) => entry.key), + ); + TestValidator.predicate( + "a universe reload replaces every producer identity and leaves no stale key", + reloadTransaction!.deletes.length === oldKeys.size && + reloadTransaction!.upserts.length === newKeys.size && + [...oldKeys].every( + (key) => + reloadTransaction!.deletes.includes(key) && !newKeys.has(key), + ), + ); + } finally { + await reload.close(); + } +} + +interface INativeLogTransaction { + tsconfig: string; + producer: { tool: string; version: string; typescript: string }; + universe: Record; + manifest: { key: string; digest: string }[]; + upserts: { + digest: string; + shard: { + key: string; + source?: { file: string }; + }; + }[]; + deletes: string[]; +} + +function goJson(value: unknown): string { + return JSON.stringify(value).replace(/[<>&\u2028\u2029]/gu, (character) => { + if (character === "<") return "\\u003c"; + if (character === ">") return "\\u003e"; + if (character === "&") return "\\u0026"; + return character === "\u2028" ? "\\u2028" : "\\u2029"; + }); +} + +function normalizeProducerUniverse( + universe: Record, +): Record { + const configs = universe.configs as { file: string; digest: string }[]; + const roots = universe.roots as { config: string; file: string }[]; + return { + configs: [...configs].sort((left, right) => + compareUtf8(left.file, right.file), + ), + roots: [...roots].sort( + (left, right) => + compareUtf8(left.config, right.config) || + compareUtf8(left.file, right.file), + ), + }; +} + +function nativeTransactions(file: string): INativeLogTransaction[] { + return fs + .readFileSync(file, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as INativeLogTransaction); +} + +function dump( + root: string, + dependency: boolean, + globalDiagnostic: boolean, +): unknown { + const bundled = "bundled:///libs/lib.es2015.collection.d.ts"; + const files = [ + "src/main.ts", + bundled, + ...(dependency ? ["vendor/dependency.d.ts"] : []), + ]; + return { + project: root, + tsconfig: "tsconfig.json", + provenance: { + schemaVersion: 6, + capabilities: [ + "universe", + "sourceDigests", + "diskDigests", + "diagnostics", + ], + producer: { + tool: "ttscgraph", + version: "0.20.1", + typescript: "5.9.0", + }, + universe: { + configs: [ + { file: "tsconfig.json", digest: sha256("configuration") }, + ], + roots: [{ config: "tsconfig.json", file: "src/main.ts" }], + }, + sources: files.map((file) => ({ + file, + checkerDigest: sha256(`${file}:checker`), + diskDigest: sha256(`${file}:disk`), + })), + }, + diagnostics: [ + ...(dependency + ? [ + { + file: "src/main.ts", + line: 1, + column: 1, + code: 2322, + category: "error", + message: "synthetic source finding", + }, + ] + : []), + ...(globalDiagnostic + ? [ + { + file: "", + line: 0, + column: 0, + code: 9999, + category: "warning", + message: "synthetic global finding", + }, + ] + : []), + ], + nodes: [ + { + id: "src/main.ts#src/main.ts:module", + kind: "module", + name: "src/main.ts", + file: "src/main.ts", + external: false, + }, + { + id: "src/main.ts#run:function", + kind: "function", + name: "run", + file: "src/main.ts", + external: false, + }, + { + id: `${bundled}#Map:interface`, + kind: "interface", + name: "Map", + file: bundled, + external: true, + }, + ...(dependency + ? [ + { + id: "vendor/dependency.d.ts#Dependency:interface", + kind: "interface", + name: "Dependency", + file: "vendor/dependency.d.ts", + external: true, + }, + ] + : []), + ], + edges: [ + { + from: "src/main.ts#run:function", + to: `${bundled}#Map:interface`, + kind: "type_ref", + }, + ...(dependency + ? [ + { + from: "src/main.ts#run:function", + to: "vendor/dependency.d.ts#Dependency:interface", + kind: "type_ref", + }, + ] + : []), + ], + }; +} diff --git a/tests/test-graph/src/features/test_ttscgraph_provider_reindexes_and_reassembles_streams.ts b/tests/test-graph/src/features/test_ttscgraph_provider_reindexes_and_reassembles_streams.ts index 5fae4842..e2389506 100644 --- a/tests/test-graph/src/features/test_ttscgraph_provider_reindexes_and_reassembles_streams.ts +++ b/tests/test-graph/src/features/test_ttscgraph_provider_reindexes_and_reassembles_streams.ts @@ -32,14 +32,18 @@ export const test_ttscgraph_provider_reindexes_and_reassembles_streams = const blank = await refreshOnce(root, "--blank-line"); TestValidator.predicate( "a blank NDJSON line is ignored and the real frame still applies", - blank.changed && blank.generation === 1 && blank.snapshot.nodes[0]?.name === "first", + blank.changed && + blank.generation === 1 && + blank.snapshot.nodes.some((node) => node.name === "first"), ); // A frame split across two stream chunks is reassembled before parsing. const split = await refreshOnce(root, "--split-frame"); TestValidator.predicate( "a frame split across stream chunks is reassembled", - split.changed && split.generation === 1 && split.snapshot.nodes[0]?.name === "first", + split.changed && + split.generation === 1 && + split.snapshot.nodes.some((node) => node.name === "first"), ); }; diff --git a/tests/test-graph/src/features/test_ttscgraph_published_legacy_dump_falls_back_honestly.ts b/tests/test-graph/src/features/test_ttscgraph_published_legacy_dump_falls_back_honestly.ts new file mode 100644 index 00000000..ae6ecca7 --- /dev/null +++ b/tests/test-graph/src/features/test_ttscgraph_published_legacy_dump_falls_back_honestly.ts @@ -0,0 +1,66 @@ +import { TestValidator } from "@nestia/e2e"; +import { buildGraphDump } from "@samchon/graph"; +import fs from "node:fs"; +import path from "node:path"; + +import { resolveTtscGraphCommand } from "../../../../packages/graph/src/provider/ttscgraph/resolveTtscGraphCommand"; +import { GraphPaths } from "../internal/GraphPaths"; + +/** + * A published producer that predates native shard negotiation cannot satisfy + * the strict incremental route merely because its complete dump is valid. + * + * 1. Resolve the workspace's independently published `ttscgraph` binary. + * 2. Ask the normal language route to index a strict TypeScript project. + * 3. Require an explicit provider refusal and an honest fallback result. + */ +export const test_ttscgraph_published_legacy_dump_falls_back_honestly = + async () => { + const resolved = resolveTtscGraphCommand(GraphPaths.graphPackageRoot); + TestValidator.predicate( + "the workspace resolves its published ttscgraph binary", + resolved !== undefined && resolved.args.length === 0, + ); + const root = GraphPaths.createTempDirectory("samchon-graph-schema3-real-"); + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { strict: true }, + include: ["src/**/*.ts"], + }), + ); + fs.writeFileSync( + path.join(root, "src", "model.ts"), + 'export type Status = "ready" | "done";\n', + ); + + const previous = process.env.TTSC_GRAPH_BINARY; + process.env.TTSC_GRAPH_BINARY = resolved!.command; + try { + const dump = await buildGraphDump({ + cwd: root, + mode: "lsp", + languages: ["typescript"], + }); + TestValidator.predicate( + "the published legacy full-dump producer falls back instead of " + + "masquerading as a shard producer", + dump.warnings?.some( + (warning) => + warning.includes("provider failed") && + warning.includes("legacy full dump"), + ) === true && + (dump.provenance ?? []).every( + (row) => row.provider !== "ttscgraph", + ), + ); + TestValidator.predicate( + "the compatibility fallback still indexes the project source", + dump.nodes.some((node) => node.file === "src/model.ts"), + ); + } finally { + if (previous === undefined) delete process.env.TTSC_GRAPH_BINARY; + else process.env.TTSC_GRAPH_BINARY = previous; + } + }; diff --git a/tests/test-graph/src/features/test_ttscgraph_published_schema3_falls_back_honestly.ts b/tests/test-graph/src/features/test_ttscgraph_published_schema3_falls_back_honestly.ts deleted file mode 100644 index 2096be19..00000000 --- a/tests/test-graph/src/features/test_ttscgraph_published_schema3_falls_back_honestly.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { TestValidator } from "@nestia/e2e"; -import { buildGraphDump } from "@samchon/graph"; -import fs from "node:fs"; -import path from "node:path"; - -import { resolveTtscGraphCommand } from "../../../../packages/graph/src/provider/ttscgraph/resolveTtscGraphCommand"; -import { GraphPaths } from "../internal/GraphPaths"; - -export const test_ttscgraph_published_schema3_falls_back_honestly = async () => { - const resolved = resolveTtscGraphCommand(GraphPaths.graphPackageRoot); - TestValidator.predicate( - "the workspace resolves its published ttscgraph binary", - resolved !== undefined && resolved.args.length === 0, - ); - const root = GraphPaths.createTempDirectory("samchon-graph-schema3-real-"); - fs.mkdirSync(path.join(root, "src"), { recursive: true }); - fs.writeFileSync( - path.join(root, "tsconfig.json"), - JSON.stringify({ compilerOptions: { strict: true }, include: ["src/**/*.ts"] }), - ); - fs.writeFileSync( - path.join(root, "src", "model.ts"), - 'export type Status = "ready" | "done";\n', - ); - - const previous = process.env.TTSC_GRAPH_BINARY; - process.env.TTSC_GRAPH_BINARY = resolved!.command; - try { - const dump = await buildGraphDump({ - cwd: root, - mode: "lsp", - languages: ["typescript"], - }); - TestValidator.predicate( - "the pinned producer's schema 6 snapshot is accepted without fallback", - dump.warnings?.every( - (warning) => - !warning.includes("provider failed") && - !warning.includes("compatibility snapshot"), - ) === true && - dump.provenance?.some( - (row) => - row.provider === "ttscgraph" && - row.producer.schemaVersion === 6, - ) === true, - ); - TestValidator.predicate( - "the strict snapshot returns the project declaration", - dump.nodes.some((node) => node.name === "Status" && node.kind === "type"), - ); - } finally { - if (previous === undefined) delete process.env.TTSC_GRAPH_BINARY; - else process.env.TTSC_GRAPH_BINARY = previous; - } -}; diff --git a/tests/test-graph/src/features/test_ttscgraph_serve_envelope_is_validated_before_it_is_routed.ts b/tests/test-graph/src/features/test_ttscgraph_serve_envelope_is_validated_before_it_is_routed.ts index 027a7c3b..b5636cc7 100644 --- a/tests/test-graph/src/features/test_ttscgraph_serve_envelope_is_validated_before_it_is_routed.ts +++ b/tests/test-graph/src/features/test_ttscgraph_serve_envelope_is_validated_before_it_is_routed.ts @@ -32,8 +32,8 @@ export const test_ttscgraph_serve_envelope_is_validated_before_it_is_routed = }); TestValidator.equals("an unchanged frame keeps its reported mode", unchanged.mode, "unchanged"); TestValidator.equals( - "an unchanged frame carries no dump", - unchanged.dump, + "an unchanged frame carries no shard transaction", + unchanged.snapshot, undefined, ); TestValidator.equals( @@ -46,7 +46,7 @@ export const test_ttscgraph_serve_envelope_is_validated_before_it_is_routed = ...base, mode: "rebuild", changed: true, - dump: { any: "body" }, + snapshot: { any: "body" }, }); TestValidator.equals( "a changed frame keeps the compiler's own word for what it did", @@ -54,8 +54,8 @@ export const test_ttscgraph_serve_envelope_is_validated_before_it_is_routed = "rebuild", ); TestValidator.equals( - "a changed frame hands its dump on untouched, for the adapter to judge", - changed.dump, + "a changed frame hands its shard transaction on untouched, for the store to judge", + changed.snapshot, { any: "body" }, ); @@ -180,25 +180,25 @@ export const test_ttscgraph_serve_envelope_is_validated_before_it_is_routed = dump: {}, }); - // `changed` decides whether a dump rides along. The producer stakes its - // atomicity claim on that pairing, so a broken one is refused here rather - // than surfacing later as an absent dump nobody expected. - rejects("a changed frame with no dump", { + // `changed` decides whether a shard transaction rides along. The producer + // stakes its atomicity claim on that pairing, so a broken one is refused + // here rather than surfacing later as absent state. + rejects("a changed frame with no shard transaction", { ...base, mode: "initial", changed: true, }); - rejects("an unchanged frame carrying a dump anyway", { + rejects("an unchanged frame carrying a shard transaction anyway", { ...base, mode: "unchanged", changed: false, - dump: {}, + snapshot: {}, }); rejects("an unchanged mode that claims the graph moved", { ...base, mode: "unchanged", changed: true, - dump: {}, + snapshot: {}, }); rejects("a rebuild mode that claims nothing moved", { ...base, diff --git a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts index 433f0868..39c1b8f3 100644 --- a/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts +++ b/tests/test-graph/src/features/test_workflows_use_current_core_action_runtimes.ts @@ -18,8 +18,18 @@ import { GraphPaths } from "../internal/GraphPaths"; * the files it was born with is not a policy. Enumerating the directory means a * new workflow is held to it by existing, and the maintained majors are named * once instead of being counted per file. + * + * Reconciled with upstream by hand, and only by hand: the deterministic suite + * may not reach the network, so nothing here can ask GitHub what the current + * major is. The check that runs is workflow-against-map, which makes this map + * an oracle only as good as the last time someone read the releases. Every + * entry was checked when `cache` was added — and `cache` is why the caveat is + * written down, because it was first added two majors behind from memory and + * nothing caught it: an action absent from this map is an action nobody + * watches. */ const MAINTAINED: Record = { + cache: 6, checkout: 7, "setup-go": 7, "setup-node": 7, @@ -27,6 +37,16 @@ const MAINTAINED: Record = { "download-artifact": 8, }; +/** + * Nothing in the product suite reads a workflow, so what CI claims about + * itself is unchecked here by default, and each way that goes wrong stays + * green until the day it matters: a retired action major, a release lane that + * publishes before it audits, a producer pin that drifts, a hang boundary + * moved off the matrix job onto GitHub's six-hour default. + * + * This reads the workflow and release-script sources directly and holds them + * to {@link MAINTAINED} and to the ordering and scoping each lane depends on. + */ export const test_workflows_use_current_core_action_runtimes = () => { const directory = path.join(GraphPaths.repositoryRoot, ".github", "workflows"); const files = fs @@ -40,8 +60,14 @@ export const test_workflows_use_current_core_action_runtimes = () => { const stale: string[] = []; for (const file of files) { const text = fs.readFileSync(path.join(directory, file), "utf8"); + // Sub-actions count as their parent. `actions/cache/restore` ships from + // the `actions/cache` repository and carries its major, so a pattern that + // stopped at the first path segment would have watched `actions/cache@v6` + // and silently ignored `actions/cache/restore@v6` — which is the form the + // experiment workflow actually uses, and would have made this map's entry + // for it dead on arrival. for (const match of text.matchAll( - /uses:\s+actions\/([\w-]+)@v(\d+)/g, + /uses:\s+actions\/([\w-]+)(?:\/[\w-]+)*@v(\d+)/g, )) { const maintained = MAINTAINED[match[1]!]; if (maintained !== undefined && Number(match[2]) !== maintained) @@ -118,10 +144,25 @@ export const test_workflows_use_current_core_action_runtimes = () => { path.join(directory, "experiment.yml"), "utf8", ); - // One hang boundary for every language. A per-language exception is how a - // budget stops being a boundary: the one lane that needed 90 minutes needed - // it because the provider had been serialized, so raising the budget was - // preserving the cause rather than bounding it. + // One boundary declaration for the whole matrix, and the exception set + // written into it rather than left to a reader. + // + // This originally refused any per-language exception, and the reason it gave + // was a cause: the lane that wanted more than ninety minutes wanted it + // because its provider had been serialized, so raising the budget preserved + // that cause instead of bounding it. The serialization was real and was + // removed, and ninety still does not fit — the same build completed in 56 + // minutes on one runner and 107 on another in one workflow, with setup and a + // real-corpus lifecycle run around it. C and C++ build a compiler from + // source and the other fourteen rows install a released producer, so the + // difference is a property of those two rows and not a defect inside them. + // + // Asserted as the exact expression. That is the same shape of assertion as + // the single number it replaces, not a tighter one — what changed is the + // policy, not the grip. The grip is what matters here: the ninety-minute + // bound still governs every other row, and a third language cannot reach the + // wider one, nor a fourth number appear, without editing this line and + // answering for it. // // Scoped to the matrix job, not to the file. Counting `timeout-minutes:` // lines across the whole workflow passes just as well when the only one has @@ -134,9 +175,74 @@ export const test_workflows_use_current_core_action_runtimes = () => { .filter((line) => line.trim().startsWith("timeout-minutes:")) .map((line) => line.trim()); TestValidator.equals( - "every real-tool language lane shares one hang boundary", + "one bound governs the matrix and only the compiler-building rows widen it", experimentTimeouts, - ["timeout-minutes: 45"], + [ + "timeout-minutes: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 150 || 90 }}", + ], + ); + // The wider bound is only defensible while an ordinary push does not reach + // it, and that depends entirely on the restore below. Pinned per step rather + // than as loose substrings over the job: four independent `includes` calls + // are satisfied by four unrelated steps, which would let the key, the path + // and the condition drift apart while the assertion stayed green. + // + // The key is pinned by its exact file list because that list is the whole + // claim. `catalog.mjs` is where the commit is actually read from, so leaving + // it out would bind the cache to the producer only by convention; the key + // would then survive a bump, the restored binary would fail its version + // check, the build would run in full, and — having hit an exact key — never + // re-save. Permanent silent full-cost rebuilding, with the widened bound as + // the normal path. + const steps = experimentSteps(experimentJob); + const restore = steps.find((step) => + step.body.includes("uses: actions/cache/restore@v6"), + ); + const save = steps.find((step) => + step.body.includes("uses: actions/cache/save@v6"), + ); + TestValidator.equals( + "the producer is restored and saved around the build, on the same key", + [ + restore?.body.includes("id: clang_producer"), + restore?.body.includes("path: tests/experiment/.work/tools"), + restore?.body.includes( + "key: clang-producer-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/graph/src/provider/cpp/CPP_CLANG_PRODUCER_COMMIT.ts', 'tests/experiment/src/catalog.mjs', 'tests/experiment/src/setup-language.mjs') }}", + ), + save?.body.includes("path: tests/experiment/.work/tools"), + save?.body.includes( + "key: ${{ steps.clang_producer.outputs.cache-primary-key }}", + ), + save?.body.includes("steps.clang_producer.outputs.cache-hit != 'true'"), + [restore, save].every((step) => + step?.body.includes("(matrix.language == 'c' || matrix.language == 'cpp')"), + ), + // Order is the whole safety argument. Saving before the build writes an + // empty tree under the exact primary key, which then restores as a hit + // forever, fails `setup`'s version check, rebuilds, and never re-saves — + // the same terminal state as saving under a key the restore cannot hit. + // Saving after the corpus run instead loses a correct build to an + // unrelated assertion. + // + // Relative, not absolute. The argument is about what comes before what, + // so pinning positions would make an unrelated step inserted anywhere + // above fail an assertion that has nothing to say about it. + isStrictlyOrdered( + [restore, "Install language server", save, "Run LSP experiment"].map( + (entry) => + typeof entry === "string" + ? steps.findIndex((step) => step.name === entry) + : (entry?.index ?? -1), + ), + ), + ], + [true, true, true, true, true, true, true, true], + ); + TestValidator.predicate( + "the Rust experiment launches the exact binary provisioned by setup", + experimentJob.includes( + "SAMCHON_GRAPH_RUST_ANALYZER_HIR: ${{ github.workspace }}/tests/experiment/.work/tools/bin/samchon-rust-analyzer", + ), ); const indexTime = fs.readFileSync( path.join(directory, "index-time.yml"), @@ -207,6 +313,45 @@ function occurrences(text: string, needle: string): number { return text.split(needle).length - 1; } +/** Whether every position was found and each one follows the last. */ +function isStrictlyOrdered(positions: readonly number[]): boolean { + return positions.every( + (position, index) => + position >= 0 && (index === 0 || position > positions[index - 1]!), + ); +} + +/** + * The matrix job's steps, in order, with their comments removed. + * + * Two properties this file needs and cannot get from a substring search over + * the whole job. Order, because the cache save has to happen after the step + * that builds and before the step that can fail for unrelated reasons, and a + * job-wide `indexOf` cannot tell those apart from a save at the top. And + * comment removal, because this workflow explains itself at length: every + * string these assertions look for also appears in prose a few lines above the + * step that implements it, so an `includes` over raw text is satisfied by the + * explanation of a step that was deleted. + */ +function experimentSteps( + job: string, +): { name: string; body: string; index: number }[] { + return job + .split(/\n - name: /u) + .slice(1) + .map((chunk, index) => { + const body = chunk + .split("\n") + .filter((line) => !/^\s*#/u.test(line)) + .join("\n"); + return { + name: body.split("\n", 1)[0]!.trim(), + body, + index, + }; + }); +} + /** * The `latest_update` job alone, so an assertion about it cannot be satisfied * by the same text sitting in a different job. diff --git a/tests/test-graph/src/internal/ContractGraph.ts b/tests/test-graph/src/internal/ContractGraph.ts index da261841..e61703eb 100644 --- a/tests/test-graph/src/internal/ContractGraph.ts +++ b/tests/test-graph/src/internal/ContractGraph.ts @@ -1,10 +1,37 @@ -import { SamchonGraphMemory, SamchonGraphApplication } from "@samchon/graph"; +import { + SamchonGraphMemory, + SamchonGraphApplication, + SamchonRepositoryContextMemory, +} from "@samchon/graph"; import type { ISamchonGraphApplication } from "@samchon/graph"; import { GraphFixtures } from "./GraphFixtures"; -const createApplication = (): SamchonGraphApplication => - new SamchonGraphApplication(SamchonGraphMemory.from(GraphFixtures.createContractFixture().dump)); +const createApplication = (): SamchonGraphApplication => { + const fixture = GraphFixtures.createContractFixture(); + return new SamchonGraphApplication( + SamchonGraphMemory.from(fixture.dump), + () => + new SamchonRepositoryContextMemory({ + project: fixture.root, + schemaVersion: 1, + inputGeneration: "a".repeat(64), + generation: { + sequence: 1, + token: "b".repeat(64), + shards: [], + contentDigest: "c".repeat(64), + }, + provenance: [], + coverage: [], + nodes: [], + edges: [], + files: [], + sources: [], + warnings: [], + }), + ); +}; const call = ( app: SamchonGraphApplication, diff --git a/tests/test-graph/src/internal/ContractParity.ts b/tests/test-graph/src/internal/ContractParity.ts index e309be64..7db1750c 100644 --- a/tests/test-graph/src/internal/ContractParity.ts +++ b/tests/test-graph/src/internal/ContractParity.ts @@ -424,6 +424,84 @@ export namespace ContractParity { // no such authority — they only trim or reword an unchanged meaning; each says // so in its reason rather than borrowing an authority it does not have. Application: [ + { + reason: + "#63 adds the operation-scoped coverage summary and provider-universe provenance to the public application output, so the application imports their public structures.", + from: + 'import { ISamchonGraphOverview } from "./ISamchonGraphOverview";', + to: [ + 'import { ISamchonGraphOverview } from "./ISamchonGraphOverview";', + 'import { ISamchonGraphCoverageSummary } from "./ISamchonGraphCoverageSummary";', + 'import { ISamchonGraphDump } from "./ISamchonGraphDump";', + ].join("\n"), + }, + { + reason: + "#63 adds the bounded unresolved summary to the same versioned application output.", + from: 'import { ISamchonGraphTrace } from "./ISamchonGraphTrace";', + to: [ + 'import { ISamchonGraphTrace } from "./ISamchonGraphTrace";', + 'import { ISamchonGraphUnresolvedSummary } from "./ISamchonGraphUnresolvedSummary";', + ].join("\n"), + }, + { + reason: + "#159 adds a typed repository-topology branch while keeping its fact plane separate from code-semantic structures.", + from: [ + 'import { ISamchonGraphTrace } from "./ISamchonGraphTrace";', + 'import { ISamchonGraphUnresolvedSummary } from "./ISamchonGraphUnresolvedSummary";', + ].join("\n"), + to: [ + 'import { ISamchonGraphTrace } from "./ISamchonGraphTrace";', + 'import { ISamchonGraphTopology } from "./ISamchonGraphTopology";', + 'import { ISamchonGraphUnresolvedSummary } from "./ISamchonGraphUnresolvedSummary";', + ].join("\n"), + }, + { + reason: + "#159 documents topology as the request for declared or owning-tool repository orientation, distinct from symbol semantics.", + layer: "prose", + from: + "- `overview`: project layers and folder structure. - `escape`: the answer is outside the graph", + to: + "- `overview`: project layers and folder structure. - `topology`: workspace, package, target, task, source-root, entrypoint, and project-dependency orientation from declared or owning-tool models. - `escape`: the answer is outside the graph", + }, + { + reason: + "#159 lists the new typed branch in the public method's operation guide.", + layer: "prose", + from: + "- `overview`: the project's layers and folder structure Every fact", + to: + "- `overview`: the project's layers and folder structure - `topology`: repository workspaces, packages, roots, targets, tasks, and dependencies Every fact", + }, + { + reason: + "The method guide keeps the unchanged tour meaning within the MCP schema generator's description limit.", + layer: "prose", + from: + "- `tour`: architecture, the runtime flow from the public API to the code that does the work, nearby paths, and the tests to read — a whole orientation in one call - `trace`:", + to: + "- `tour`: architecture, runtime flow, nearby paths, and tests - `trace`:", + }, + { + reason: + "#159 adds the versioned repository-topology request to the existing single MCP tool.", + from: "| ISamchonGraphEscape.IRequest;", + to: [ + "| ISamchonGraphTopology.IRequest", + "| ISamchonGraphEscape.IRequest;", + ].join("\n"), + }, + { + reason: + "#159 returns the typed repository-topology result beside the existing code-semantic result branches.", + from: "| ISamchonGraphEscape;", + to: [ + "| ISamchonGraphTopology", + "| ISamchonGraphEscape;", + ].join("\n"), + }, { reason: "The compiler resolves a fact and verifies it; the index checks it. The same guarantee, named for the authority that gives it.", @@ -483,7 +561,7 @@ export namespace ContractParity { "There is no compiler to own the index; the repository's own program index answers the question.", layer: "prose", from: "Answer a __LANG__ question from the compiler's own index of this repository.", - to: "Answer a __LANG__ question from this repository's own program index.", + to: "Answer a __LANG__ question from the repository's program index.", }, { reason: @@ -494,10 +572,10 @@ export namespace ContractParity { }, { reason: - "No authority: the sentence is reworded with no change of meaning (a comma becomes `or`, `in` becomes `inside`).", + "No authority: the sentence is shortened without changing the boundary between graph facts and source text.", layer: "prose", from: "Read a file for what the graph does not carry: a body, the text in a span.", - to: "Read a file for what the graph does not carry: a body or the text inside a span.", + to: "Read source only for a body or span text.", }, { reason: @@ -520,6 +598,46 @@ export namespace ContractParity { from: "For the ranked operations (`lookup`, `entrypoints`, `tour`) it adds that the selection is heuristic — matched, scored, ranked, and limited against the question — so the facts are verified but the shortlist's coverage is the caller's to judge.", to: "For ranked operations (`lookup`, `entrypoints`, `tour`) it additionally says that selection was matched, scored, ranked, and limited against the question, so the facts are checked but shortlist coverage is yours to judge.", }, + { + reason: + "#63 replaces the compiler-completeness overclaim with the exact contract: returned facts are proved, while coverage and uncertainty say whether missing facts are meaningful.", + layer: "prose", + from: + "The graph holds every symbol, call, type, decorator and test, each with its file and line, resolved from the source on disk now. Submit exactly one request:", + to: + "The graph returns proved facts with coverage and uncertainty. Submit one request:", + }, + { + reason: + "#63 version 1 adds provider/universe identity plus operation-scoped coverage and unresolved summaries beside `audit`; optionality preserves escape and legacy dump compatibility.", + from: "audit: string;", + to: [ + "audit: string;", + "provenance?: ISamchonGraphDump.IProvenance[];", + "coverage?: ISamchonGraphCoverageSummary;", + "unresolved?: ISamchonGraphUnresolvedSummary;", + ].join("\n"), + }, + { + reason: + "The structure rule above adds the versioned trust fields; this prose rule records their exact public semantics without hiding them behind the English audit.", + layer: "prose", + from: [ + "audit: string;", + "provenance?: ISamchonGraphDump.IProvenance[];", + "coverage?: ISamchonGraphCoverageSummary;", + "unresolved?: ISamchonGraphUnresolvedSummary;", + ].join("\n"), + to: [ + "audit: string;", + "/** Strict producer, authority, compiler and build-universe identity for the synchronized graph. Absent for `escape`, for `topology` whose facts come from the repository plane and carry their own provenance, and for a legacy or fallback-only dump with no strict producer. */", + "provenance?: ISamchonGraphDump.IProvenance[];", + "/** Machine-readable completeness for the relationship families relevant to this operation. Absent for `escape` and for `topology`, which reports its own relation coverage inside the result. */", + "coverage?: ISamchonGraphCoverageSummary;", + "/** Bounded structured uncertainty for the same operation-scoped families. Absent for `escape` and for `topology`, whose plane publishes none. */", + "unresolved?: ISamchonGraphUnresolvedSummary;", + ].join("\n"), + }, ], Details: [ { @@ -601,6 +719,33 @@ export namespace ContractParity { 'indexer: "lsp" | "static" | "hybrid";', ].join("\n"), }, + { + reason: + "#159 adds a deterministic code-input generation so an MCP request can fence a topology load between two code refreshes before admitting file joins.", + from: 'indexer: "lsp" | "static" | "hybrid";', + to: [ + 'indexer: "lsp" | "static" | "hybrid";', + "generation?: {", + "input: string;", + "};", + ].join("\n"), + }, + { + reason: + "The generation shape above is structural; this prose rule records that it exists for cross-plane fencing and remains optional only for legacy dumps.", + layer: "prose", + from: [ + "generation?: {", + "input: string;", + "};", + ].join("\n"), + to: [ + "/** Complete coordinator input generation used to fence code/topology joins. Absent only on dumps written before cross-plane generation fencing. */", + "generation?: {", + "input: string;", + "};", + ].join("\n"), + }, { reason: "The reference proves one TypeScript Program. The public multi-language dump cannot present one provider's proof as authority for every language; #66 owns the provider registry and its eventual public provenance shape. Reduce the reviewed prose block to its code before removing the same exact structure at both fidelities.", @@ -837,6 +982,51 @@ export namespace ContractParity { from: "/** Expression span; its file is the one embedded in `from`. */", to: "/** Expression span; its file is the source node's declaration file. */", }, + { + reason: + "#63 makes normalized completeness part of the public dump contract.", + from: 'import { ISamchonGraphEdge } from "./ISamchonGraphEdge";', + to: [ + 'import { ISamchonGraphEdge } from "./ISamchonGraphEdge";', + 'import { ISamchonGraphCoverage } from "./ISamchonGraphCoverage";', + ].join("\n"), + }, + { + reason: + "#63 preserves structured unresolved sites in the public dump.", + from: 'import { ISamchonGraphSpan } from "./ISamchonGraphSpan";', + to: [ + 'import { ISamchonGraphSpan } from "./ISamchonGraphSpan";', + 'import { ISamchonGraphUnresolved } from "./ISamchonGraphUnresolved";', + ].join("\n"), + }, + { + reason: + "#63 adds additive optional coverage and unresolved fields after provider provenance so older dumps remain loadable during protocol migration.", + from: "provenance?: ISamchonGraphDump.IProvenance[];", + to: [ + "provenance?: ISamchonGraphDump.IProvenance[];", + "coverage?: ISamchonGraphCoverage[];", + "unresolved?: ISamchonGraphUnresolved[];", + ].join("\n"), + }, + { + reason: + "The structure rule above adds dump trust fields; their prose distinguishes migration absence from explicit empty uncertainty.", + layer: "prose", + from: [ + "provenance?: ISamchonGraphDump.IProvenance[];", + "coverage?: ISamchonGraphCoverage[];", + "unresolved?: ISamchonGraphUnresolved[];", + ].join("\n"), + to: [ + "provenance?: ISamchonGraphDump.IProvenance[];", + "/** Exhaustive per-provider, language, target and relationship-family completeness rows. Absent only on dumps written before protocol version 1. */", + "coverage?: ISamchonGraphCoverage[];", + "/** Structured relationship sites that a producer could not resolve exactly. An empty list is meaningful only together with exhaustive coverage. */", + "unresolved?: ISamchonGraphUnresolved[];", + ].join("\n"), + }, ], Edge: [ { diff --git a/tests/test-graph/src/internal/GraphFixtures.ts b/tests/test-graph/src/internal/GraphFixtures.ts index 94af2a81..6536c9b8 100644 --- a/tests/test-graph/src/internal/GraphFixtures.ts +++ b/tests/test-graph/src/internal/GraphFixtures.ts @@ -56,6 +56,7 @@ const GRAPH_REQUEST_TYPES = [ "details", "overview", "tour", + "topology", "escape", ]; diff --git a/tests/test-graph/src/internal/GraphPaths.ts b/tests/test-graph/src/internal/GraphPaths.ts index ca0136da..6b99a589 100644 --- a/tests/test-graph/src/internal/GraphPaths.ts +++ b/tests/test-graph/src/internal/GraphPaths.ts @@ -63,6 +63,8 @@ export const GraphPaths = { createTempDirectory, fakeCmake: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-cmake.cjs"), fakeLspServer: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-lsp-server.cjs"), + fakeCppGraphServer: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-cpp-graph-server.cjs"), + fakeRustGraphServer: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-rust-graph-server.cjs"), fakeTtscGraphServer: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-ttscgraph-server.cjs"), fakePub: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-pub.cjs"), fakeScipIndexer: path.join(repositoryRoot, "tests", "test-graph", "src", "internal", "fake-scip-indexer.cjs"), diff --git a/tests/test-graph/src/internal/fake-cpp-graph-server.cjs b/tests/test-graph/src/internal/fake-cpp-graph-server.cjs new file mode 100644 index 00000000..5ff2b3ad --- /dev/null +++ b/tests/test-graph/src/internal/fake-cpp-graph-server.cjs @@ -0,0 +1,633 @@ +#!/usr/bin/env node +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); + +const args = process.argv.slice(2); +const valueOf = (prefix) => + args.find((argument) => argument.startsWith(prefix))?.slice(prefix.length); +const commit = + valueOf("--commit=") ?? "1111111111111111111111111111111111111111"; +const requestLog = valueOf("--request-log="); +const watchLog = valueOf("--watch-log="); +let retry = Number(valueOf("--retry=") ?? 0); +let contentModified = Number(valueOf("--content-modified=") ?? 0); +const moveInputOnContentModified = args.includes( + "--move-input-on-content-modified", +); +const hang = args.includes("--hang"); +const internalError = args.includes("--internal-error"); +const malformed = args.includes("--malformed"); +const initializeError = args.includes("--initialize-error"); +const hangInitialize = args.includes("--hang-initialize"); +const requestConfiguration = args.includes("--request-configuration"); +const requestEmptyConfiguration = args.includes("--request-empty-configuration"); +const requestUnknown = args.includes("--request-unknown"); +const pageCorruption = valueOf("--page-corruption="); +const edgeCases = args.includes("--edge-cases"); +const invalidSourceUri = args.includes("--invalid-source-uri"); +const unsupportedSourceUri = args.includes("--unsupported-source-uri"); +const checkerOverlay = args.includes("--checker-overlay"); +const emptyDiskDigest = args.includes("--empty-disk-digest"); +const EDGE_KINDS = [ + "contains", "exports", "imports", "calls", "accesses", + "instantiates", "type_ref", "extends", "implements", "overrides", + "dispatches", "decorates", "renders", "tests", "references", +]; +const COVERAGE = { + contains: "complete", + exports: "partial", + imports: "complete", + calls: "partial", + accesses: "complete", + instantiates: "partial", + type_ref: "complete", + extends: "complete", + implements: "partial", + overrides: "complete", + dispatches: "partial", + decorates: "unsupported", + renders: "unsupported", + tests: "unsupported", + references: "complete", +}; +let sequence = 0; +let published; +let activePlan; + +if (args.includes("--version")) { + process.stdout.write(`clangd version 22.1.8 (${commit})\n`); + process.exit(0); +} +if (args.includes("--snapshot")) { + process.stdout.write(JSON.stringify(snapshot(null, undefined, 32))); + process.exit(0); +} + +let buffer = Buffer.alloc(0); + +process.stdin.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + for (;;) { + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) return; + const header = buffer.slice(0, headerEnd).toString("ascii"); + const length = Number(/Content-Length:\s*(\d+)/i.exec(header)?.[1]); + const bodyStart = headerEnd + 4; + const bodyEnd = bodyStart + length; + if (!Number.isSafeInteger(length) || buffer.length < bodyEnd) return; + const message = JSON.parse(buffer.slice(bodyStart, bodyEnd).toString("utf8")); + buffer = buffer.slice(bodyEnd); + handle(message); + } +}); + +function handle(message) { + if (message.method === "initialize") { + if (hangInitialize) return; + if (initializeError) { + sendError(message.id, -32603, "fixture initialize failure"); + return; + } + send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } }); + if (requestConfiguration) { + send({ + jsonrpc: "2.0", + id: "fixture-configuration", + method: "workspace/configuration", + params: { items: [{ section: "clangd" }, { section: "clangd.graph" }] }, + }); + } + if (requestEmptyConfiguration) { + send({ + jsonrpc: "2.0", + id: "fixture-empty-configuration", + method: "workspace/configuration", + params: {}, + }); + } + if (requestUnknown) { + send({ + jsonrpc: "2.0", + id: "fixture-unknown", + method: "workspace/unknown", + params: {}, + }); + } + return; + } + if (message.method === "workspace/didChangeWatchedFiles") { + if (watchLog !== undefined) { + fs.appendFileSync(watchLog, `${JSON.stringify(message.params)}\n`); + } + return; + } + if (message.method === "samchon/graphSnapshot") { + if (requestLog !== undefined) { + fs.appendFileSync(requestLog, `${JSON.stringify(message.params)}\n`); + } + if (hang) return; + if (internalError) { + sendError(message.id, -32603, "fixture internal failure"); + return; + } + if (retry > 0) { + retry -= 1; + sendError(message.id, -32802, "fixture graph is not ready"); + return; + } + if (contentModified > 0) { + contentModified -= 1; + if (moveInputOnContentModified) { + fs.writeFileSync( + path.join(process.cwd(), "main.cpp"), + "void moved_during_snapshot() {}\n", + ); + } + sendError(message.id, -32801, "fixture graph moved"); + return; + } + let result = snapshot( + message.params?.knownGeneration ?? null, + message.params?.cursor, + message.params?.maxShards ?? 32, + ); + if (malformed) result.producer.commit = "wrong"; + result = corruptPage(result, message.params?.cursor !== undefined); + send({ jsonrpc: "2.0", id: message.id, result }); + return; + } + if (message.method === "shutdown") { + send({ jsonrpc: "2.0", id: message.id, result: null }); + return; + } + if (message.method === "exit") process.exit(0); +} + +function corruptPage(result, continuation) { + if (pageCorruption === "generation") return null; + if (pageCorruption === "envelope") result.page.offset = -1; + if (pageCorruption === "telemetry") result.phases.validationMillis = -1; + if (pageCorruption === "cache") result.phases.cacheHit = "invalid"; + if (pageCorruption === "early" && !continuation) result.page.nextCursor = null; + if (pageCorruption === "cursor" && !continuation) { + result.page.total = result.page.count; + } + if (pageCorruption === "metadata" && continuation) { + result.manifest = [{ key: "repeated", digest: "0".repeat(64) }]; + } + if (pageCorruption === "cross-generation" && continuation) { + result.sequence += 1; + } + return result; +} + +function snapshot(knownGeneration, cursor, maxShards) { + if (cursor !== undefined) { + if (activePlan === undefined || !cursor.startsWith(`${activePlan.token}:`)) { + throw new Error("fixture stale graph cursor"); + } + return pageOf(activePlan, Number(cursor.slice(cursor.lastIndexOf(":") + 1)), maxShards); + } + const prior = published; + const shards = compilationCommands().map(graphShard).sort(compareKey); + const manifest = shards.map((shard) => ({ + digest: shard.digest, + key: shard.key, + })); + const targets = [...new Set(shards.map((shard) => shard.graph.targetTriple))] + .sort(); + const configurations = [ + ...new Set(shards.map((shard) => shard.configuration)), + ].sort(); + const producer = { + name: "samchon-clangd", + version: "clang version 22.1.8", + commit, + }; + const fingerprint = producerFingerprint(producer); + const workspaceRoots = [canonicalRoot(process.cwd())]; + const toolchains = [ + ...new Set(shards.map((shard) => shard.graph.toolchainFingerprint)), + ].sort(); + let universeMaterial = coordinate("producer", fingerprint); + for (const target of targets) universeMaterial += coordinate("target", target); + for (const root of workspaceRoots) universeMaterial += coordinate("root", root); + for (const toolchain of toolchains) + universeMaterial += coordinate("toolchain", toolchain); + for (const configuration of configurations) + universeMaterial += coordinate("configuration", configuration); + const universe = digest(universeMaterial); + const generationMaterial = manifest + .map((entry) => `${Buffer.byteLength(entry.key)}:${entry.key}${entry.digest}`) + .join(""); + const generation = digest(universe + generationMaterial); + const noChange = knownGeneration === generation; + const delta = !noChange && prior?.generation === knownGeneration; + const previous = new Map( + (prior?.manifest ?? []).map((entry) => [entry.key, entry.digest]), + ); + const current = new Map(manifest.map((entry) => [entry.key, entry.digest])); + const upserts = noChange + ? [] + : delta + ? shards.filter((shard) => previous.get(shard.key) !== shard.digest) + : shards; + const deletes = delta + ? [...previous.keys()].filter((key) => !current.has(key)).sort() + : []; + sequence += 1; + activePlan = { + token: digest(`${generation}:${sequence}:${knownGeneration ?? "full"}`), + protocolVersion: 1, + schemaVersion: 1, + producer, + universe: { + digest: universe, + targets, + workspaceRoots, + toolchains, + configurations, + }, + sequence, + generation, + baseGeneration: noChange || delta ? knownGeneration : null, + upserts, + deletes, + manifest, + cacheHit: noChange, + }; + published = { generation, manifest }; + return pageOf(activePlan, 0, maxShards); +} + +function pageOf(plan, offset, maxShards) { + const pageSize = Math.max(1, Math.min(128, Number(maxShards) || 32)); + const end = Math.min(plan.upserts.length, offset + pageSize); + const semanticMillis = offset === 0 && !plan.cacheHit ? 1 : 0; + const shardMillis = offset === 0 && !plan.cacheHit ? 1 : 0; + const validationMillis = 1; + const encodeMillis = 1; + return { + protocolVersion: plan.protocolVersion, + schemaVersion: plan.schemaVersion, + producer: plan.producer, + universe: plan.universe, + sequence: plan.sequence, + generation: plan.generation, + baseGeneration: plan.baseGeneration, + upserts: plan.upserts.slice(offset, end), + deletes: offset === 0 ? plan.deletes : [], + manifest: offset === 0 && !plan.cacheHit ? plan.manifest : [], + page: { + offset, + count: end - offset, + total: plan.upserts.length, + nextCursor: + end < plan.upserts.length ? `${plan.token}:${end}` : null, + }, + phases: { + validationMillis, + semanticMillis, + shardMillis, + encodeMillis, + totalMillis: + validationMillis + semanticMillis + shardMillis + encodeMillis, + cacheHit: plan.cacheHit, + }, + }; +} + +function compilationCommands() { + for (const candidate of [ + path.join(process.cwd(), "compile_commands.json"), + path.join(process.cwd(), "build", "compile_commands.json"), + ]) { + try { + const rows = JSON.parse(fs.readFileSync(candidate, "utf8")); + if (Array.isArray(rows)) { + return rows.filter( + (row) => typeof row?.file === "string" && row.file !== "", + ); + } + } catch {} + } + return []; +} + +function graphShard(command) { + const directory = path.resolve(command.directory || process.cwd()); + const mainFile = path.resolve(directory, command.file); + const mainFileUri = pathToFileURL(mainFile).href; + const commandLine = Array.isArray(command.arguments) + ? command.arguments + : String(command.command || "clang++ -c fixture.cpp").split(/\s+/u); + const commandDigest = digest( + `${directory.length}:${directory}${mainFile.length}:${mainFile}${commandLine + .map((argument) => `${argument.length}:${argument}`) + .join("")}`, + ); + const language = commandLine.some((argument) => argument === "c") || + path.extname(mainFile).toLowerCase() === ".c" + ? "c" + : "cpp"; + const targetTriple = "x86_64-pc-windows-msvc"; + const diskText = fs.readFileSync(mainFile); + const sourceText = checkerOverlay + ? Buffer.concat([Buffer.from("// checker overlay\n"), diskText]) + : diskText; + const sourceDigest = digest(sourceText); + const diskDigest = emptyDiskDigest ? "" : digest(diskText); + const text = sourceText.toString("utf8"); + const callerRange = wordRanges(text, mainFileUri, "caller")[0] ?? + range(mainFileUri, 0, 0, 0, 0); + const calleeRanges = wordRanges(text, mainFileUri, "callee"); + const calleeReference = calleeRanges.at(-2) ?? callerRange; + const calleeDefinition = calleeRanges.at(-1) ?? callerRange; + const sourceRange = callerRange; + const callerName = sourceText.includes("edited") ? "editedCaller" : "caller"; + const caller = symbol("c:@F@caller#", callerName, 13, callerRange, true); + const callee = symbol("c:@F@callee#", "callee", 13, calleeDefinition, true); + const base = symbol("c:@S@Base", "Base", 7, sourceRange, true); + const derived = symbol("c:@S@Derived", "Derived", 7, sourceRange, true); + const constructor = symbol("c:@S@Derived@F@Derived#", "Derived", 23, sourceRange, true); + const field = symbol("c:@S@Derived@FI@value", "value", 15, sourceRange, false); + const symbols = [caller, callee, base, derived, constructor, field]; + const sources = [{ + uri: mainFileUri, + digest: sourceDigest, + diskDigest, + flags: 1, + }]; + const header = path.join(directory, "include", "fixture.h"); + let includes = []; + try { + const headerUri = pathToFileURL(header).href; + const headerDigest = digest(fs.readFileSync(header)); + sources.push({ + uri: headerUri, + digest: headerDigest, + diskDigest: headerDigest, + flags: 0, + }); + includes = [{ + source: mainFileUri, + target: headerUri, + spelling: "fixture.h", + angled: false, + moduleImported: false, + evidence: sourceRange, + }]; + } catch {} + const graph = { + producerFingerprint: producerFingerprint({ + version: "clang version 22.1.8", + commit, + }), + mainFileUri, + mainFile, + directory, + commandLine, + output: typeof command.output === "string" ? command.output : "", + commandDigest, + toolchainFingerprint: digest("fixture-toolchain"), + targetTriple, + language, + hadErrors: false, + sources, + symbols, + occurrences: [ + occurrence(callee.id, caller.id, (1 << 2) | (1 << 5), 13, calleeReference), + occurrence(constructor.id, caller.id, (1 << 2) | (1 << 5), 23, sourceRange), + occurrence(base.id, derived.id, 1 << 2, 7, sourceRange), + occurrence(field.id, caller.id, (1 << 2) | (1 << 3), 15, sourceRange), + occurrence(callee.id, caller.id, (1 << 2) | (1 << 5) | (1 << 6), 13, sourceRange), + ], + relations: [ + relation(base.id, derived.id, 1 << 11, sourceRange), + relation(derived.id, base.id, 1 << 12, sourceRange), + relation(callee.id, caller.id, 1 << 14, sourceRange), + relation(field.id, derived.id, 1 << 10, sourceRange), + relation(field.id, caller.id, 1 << 16, sourceRange), + relation(derived.id, base.id, 1 << 19, sourceRange), + ], + macros: [ + { + usr: "c:@macro@FIXTURE", + id: "c:@macro@FIXTURE|ordinal=0", + name: "FIXTURE", + roles: 1 << 1, + definition: sourceRange, + spelling: sourceRange, + expansion: sourceRange, + }, + { + usr: "c:@macro@FIXTURE", + id: "c:@macro@FIXTURE|ordinal=0", + name: "FIXTURE", + roles: 1 << 2, + definition: sourceRange, + spelling: sourceRange, + expansion: sourceRange, + }, + ], + includes, + missingIncludes: [], + modules: [{ name: "fixture.module", roles: 1 << 2, evidence: sourceRange }], + diagnostics: [{ + message: "fixture warning", + code: "clang:1", + severity: "warning", + range: sourceRange, + }], + }; + if (edgeCases) applyEdgeCases(graph, sourceRange, caller, callee, base, derived, constructor); + if (invalidSourceUri) { + graph.sources.push({ + uri: "file:%", + digest: digest("invalid-uri"), + diskDigest: "", + flags: 0, + }); + } + if (unsupportedSourceUri) { + graph.sources.push({ + uri: "repo:///fixture/unknown.hpp", + digest: digest("unsupported-source-uri"), + diskDigest: "", + flags: 0, + }); + } + const key = `${mainFileUri}#${commandDigest}`; + const interfaceFingerprint = digest( + symbols + .filter((entry) => entry.exported) + .map((entry) => `${entry.id.length}:${entry.id}${entry.signature.length}:${entry.signature}`) + .join(""), + ); + const shard = { + key, + source: mainFile, + configuration: commandDigest, + checkerDigest: sourceDigest, + interfaceFingerprint, + digest: "", + graph, + coverage: EDGE_KINDS.map((family) => ({ + family, + state: COVERAGE[family], + })), + }; + shard.digest = digest( + `${key}\n${sourceDigest}\n${interfaceFingerprint}\n${JSON.stringify(graph)}`, + ); + return shard; +} + +function applyEdgeCases(graph, location, caller, callee, base, derived, constructor) { + const empty = range("", 0, 0, 0, 0); + caller.qualifiedName = "fixture::caller"; + callee.qualifiedName = ""; + base.ownerUsr = caller.id; + derived.definition = empty; + constructor.kind = 999; + constructor.declaration = empty; + constructor.definition = empty; + constructor.attributes = []; + graph.sources.push( + { + uri: "bundled:///fixture/system.h", + digest: digest("bundled"), + diskDigest: "", + flags: 0, + }, + { + uri: "relative.cpp", + digest: digest("relative"), + diskDigest: "", + flags: 0, + }, + { + uri: path.join(graph.directory, "absolute.cpp"), + digest: digest("absolute"), + diskDigest: digest("absolute"), + flags: 0, + }, + ); + graph.occurrences[0].containerId = ""; + graph.occurrences[0].expansion = empty; + graph.occurrences.push(occurrence(callee.id, "", 1 << 2, 13, empty)); + graph.relations.push( + relation(base.id, derived.id, 1 << 15, location), + relation(caller.id, caller.id, 1 << 12, location), + relation(caller.id, callee.id, 1 << 12, empty), + relation("c:@F@external#", caller.id, 1 << 12, location), + ); + graph.macros[0].definition = empty; + graph.macros[0].spelling = empty; + graph.macros[0].expansion = empty; + graph.macros[1].spelling = empty; + graph.macros[1].expansion = empty; + graph.modules[0].evidence = empty; + graph.diagnostics[0].range = empty; +} + +function symbol(id, name, kind, location, exported) { + return { + usr: id, + id, + name, + qualifiedName: name, + ownerUsr: "", + signature: kind === 13 ? "void ()" : "", + kind, + subKind: 0, + properties: 0, + local: false, + internal: !exported, + anonymous: false, + exported, + declaration: location, + definition: location, + attributes: [{ name: "nodiscard", range: location }], + }; +} + +function occurrence(id, containerId, roles, targetKind, location) { + return { + usr: id, + id, + containerId, + roles, + targetKind, + spelling: location, + expansion: location, + }; +} + +function relation(subjectId, objectId, roles, evidence) { + return { subjectId, objectId, roles, evidence }; +} + +function range(file, startLine, startColumn, endLine, endColumn) { + return { file, startLine, startColumn, endLine, endColumn }; +} + +function wordRanges(text, file, word) { + const output = []; + let offset = 0; + for (;;) { + const found = text.indexOf(word, offset); + if (found < 0) return output; + const prefix = text.slice(0, found); + const line = prefix.split("\n").length - 1; + const lastNewline = prefix.lastIndexOf("\n"); + const column = found - lastNewline - 1; + output.push(range(file, line, column, line, column + word.length)); + offset = found + word.length; + } +} + +function digest(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function coordinate(label, value) { + return `${label}:${Buffer.byteLength(value)}:${value}`; +} + +function producerFingerprint(producer) { + return digest( + `samchon-graph-schema:1\nversion:${producer.version}\nrepository:${producer.commit}`, + ); +} + +function canonicalRoot(root) { + const slash = path.resolve(root).replace(/\\/gu, "/"); + return process.platform === "win32" ? slash.toLowerCase() : slash; +} + +function compareKey(left, right) { + const source = Buffer.compare( + Buffer.from(left.source, "utf8"), + Buffer.from(right.source, "utf8"), + ); + if (source !== 0) return source; + return Buffer.compare( + Buffer.from(left.configuration, "utf8"), + Buffer.from(right.configuration, "utf8"), + ); +} + +function send(message) { + const body = Buffer.from(JSON.stringify(message)); + process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`); + process.stdout.write(body); +} + +function sendError(id, code, message) { + send({ jsonrpc: "2.0", id, error: { code, message } }); +} diff --git a/tests/test-graph/src/internal/fake-rust-graph-server.cjs b/tests/test-graph/src/internal/fake-rust-graph-server.cjs new file mode 100644 index 00000000..edf40a34 --- /dev/null +++ b/tests/test-graph/src/internal/fake-rust-graph-server.cjs @@ -0,0 +1,397 @@ +#!/usr/bin/env node +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +const args = process.argv.slice(2); +const valueOf = (prefix) => args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length); +const commit = valueOf("--commit=") ?? "95f4050923a1d80a29147f4b66614c843c26b183"; +const requestLog = valueOf("--request-log="); +const retrySentMarker = valueOf("--retry-sent-marker="); +const initializeMarker = valueOf("--initialize-marker="); +const initializeDelay = Number(valueOf("--initialize-delay=") ?? 0); +const marker = valueOf("--marker="); +let retry = Number(valueOf("--retry=") ?? 0); +let contentModified = Number(valueOf("--content-modified=") ?? 0); +const rejectCheckpoint = args.includes("--reject-checkpoint"); +const hang = args.includes("--hang"); +const internalError = args.includes("--internal-error"); +const malformed = args.includes("--malformed"); +const configurationWithoutItems = args.includes("--configuration-without-items"); +const expectInitializationOptions = args.includes("--expect-initialization-options"); +const initializeError = args.includes("--initialize-error"); +const failVersion = args.includes("--fail-version"); +const versionCommitLength = Number(valueOf("--version-commit-length=") ?? 9); +const conformance = args.includes("--conformance"); +const conformanceHeuristic = args.includes("--conformance-heuristic"); + +if (args.includes("--version")) { + if (failVersion) process.exit(7); + process.stdout.write( + `rust-analyzer 1.95.0 (${commit.slice(0, versionCommitLength)} 2026-08-01)\n`, + ); + process.exit(0); +} + +const EDGE_KINDS = [ + "contains", + "exports", + "imports", + "calls", + "accesses", + "instantiates", + "type_ref", + "extends", + "implements", + "overrides", + "dispatches", + "decorates", + "renders", + "tests", + "references", +]; +const universe = sha256("fixture-rust-universe"); +const evidence = conformance + ? { + file: "src/lib.rs", + startLine: 2, + startColumn: 8, + endLine: 2, + endColumn: 14, + } + : { + file: "src/lib.rs", + startLine: 1, + startColumn: 1, + endLine: 1, + endColumn: 20, + }; +const calleeEvidence = { + file: "src/lib.rs", + startLine: 3, + startColumn: 8, + endLine: 3, + endColumn: 14, +}; +const callEvidence = { + file: "src/lib.rs", + startLine: 2, + startColumn: 19, + endLine: 2, + endColumn: 25, +}; +const nodes = conformance + ? [ + { + id: "rust-hir-v1|fixture-caller", + kind: "function", + name: "caller", + qualifiedName: "fixture::caller", + file: "src/lib.rs", + external: false, + exported: true, + signature: "fn()", + evidence, + }, + { + id: "rust-hir-v1|fixture-callee", + kind: "function", + name: "callee", + qualifiedName: "fixture::callee", + file: "src/lib.rs", + external: false, + exported: true, + signature: "fn()", + evidence: calleeEvidence, + }, + ...(conformanceHeuristic + ? [ + { + id: "rust-hir-v1|fixture-comment", + kind: "function", + name: "mentionedInComment", + qualifiedName: "fixture::mentionedInComment", + file: "src/lib.rs", + external: false, + exported: false, + signature: "fn()", + evidence: { + file: "src/lib.rs", + startLine: 1, + startColumn: 4, + endLine: 1, + endColumn: 22, + }, + }, + ] + : []), + ] + : [ + { + id: "rust-hir-v1|fixture-answer", + kind: "function", + name: "answer", + qualifiedName: "fixture::answer", + file: "src/lib.rs", + external: false, + exported: true, + signature: "fn() -> u8", + evidence, + }, + { + id: "rust-hir-v1|fixture-dependency", + kind: "function", + name: "dependency", + qualifiedName: null, + file: "bundled:///rust/dependencies", + external: true, + exported: false, + signature: null, + evidence: null, + }, + ]; +const edges = conformance + ? [ + { + from: "rust-hir-v1|fixture-caller", + to: "rust-hir-v1|fixture-callee", + kind: "calls", + evidence: callEvidence, + }, + ...(conformanceHeuristic + ? [ + { + from: "rust-hir-v1|fixture-caller", + to: "rust-hir-v1|fixture-comment", + kind: "calls", + evidence: { + file: "src/lib.rs", + startLine: 1, + startColumn: 4, + endLine: 1, + endColumn: 22, + }, + }, + ] + : []), + ] + : [ + { + from: "rust-hir-v1|fixture-answer", + to: "rust-hir-v1|fixture-dependency", + kind: "calls", + evidence, + }, + ]; +const shard = { + key: "app\u0000src/lib.rs", + source: "src/lib.rs", + checkerDigest: crypto + .createHash("sha256") + .update(fs.readFileSync(path.join(process.cwd(), "src/lib.rs"))) + .digest("hex"), + interfaceFingerprint: sha256( + conformanceHeuristic ? "fixture-heuristic-interface" : "fixture-interface", + ), + digest: "", + nodes, + edges, + diagnostics: [ + { + file: "src/lib.rs", + line: 1, + column: null, + code: "fixture", + message: "fixture diagnostic", + severity: "warning", + }, + ], + coverage: EDGE_KINDS.map((family) => ({ + family, + state: family === "renders" ? "unsupported" : "partial", + })), + unresolved: EDGE_KINDS.filter((family) => family !== "renders").map((family) => ({ + family, + evidence, + reason: "provider-gap", + candidates: [], + })), +}; +shard.digest = sha256({ + key: shard.key, + source: shard.source, + checkerDigest: shard.checkerDigest, + interfaceFingerprint: shard.interfaceFingerprint, + nodes: shard.nodes, + edges: shard.edges, + diagnostics: shard.diagnostics, + coverage: shard.coverage, + unresolved: shard.unresolved, +}); +const manifest = [{ key: shard.key, digest: shard.digest }]; +const generation = sha256({ universe, manifest }); +let sequence = 0; +let buffer = Buffer.alloc(0); +let initializeRequest; +let serverRequestPhase = 0; + +process.stdin.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + for (;;) { + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) return; + const header = buffer.slice(0, headerEnd).toString("ascii"); + const length = Number(/Content-Length:\s*(\d+)/i.exec(header)?.[1]); + const bodyStart = headerEnd + 4; + const bodyEnd = bodyStart + length; + if (!Number.isSafeInteger(length) || buffer.length < bodyEnd) return; + const message = JSON.parse(buffer.slice(bodyStart, bodyEnd).toString("utf8")); + buffer = buffer.slice(bodyEnd); + handle(message); + } +}); + +process.stdin.on("end", finish); + +function handle(message) { + if (message.method === "initialize") { + if ( + expectInitializationOptions && + JSON.stringify(message.params?.initializationOptions) !== '{"fixture":true}' + ) { + process.exitCode = 33; + } + initializeRequest = message.id; + if (initializeMarker !== undefined) fs.writeFileSync(initializeMarker, "started"); + if (initializeDelay > 0) { + setTimeout(requestConfiguration, initializeDelay); + return; + } + requestConfiguration(); + return; + } + if (message.id === 9001 && serverRequestPhase === 0) { + const expected = configurationWithoutItems ? "[]" : "[null,null]"; + if (JSON.stringify(message.result) !== expected) process.exitCode = 31; + serverRequestPhase = 1; + send({ jsonrpc: "2.0", id: 9002, method: "fixture/unknown", params: {} }); + return; + } + if (message.id === 9002 && serverRequestPhase === 1) { + if (message.result !== null) process.exitCode = 32; + serverRequestPhase = 2; + if (initializeError) { + sendError(initializeRequest, -32603, "fixture initialize failure"); + } else { + send({ jsonrpc: "2.0", id: initializeRequest, result: { capabilities: {} } }); + } + return; + } + if (message.method === "samchon/graphSnapshot") { + if (requestLog !== undefined) fs.appendFileSync(requestLog, `${JSON.stringify(message.params)}\n`); + if (hang) return; + if (internalError) { + sendError(message.id, -32603, "fixture internal failure"); + return; + } + if (message.params?.checkpoint !== undefined && rejectCheckpoint) { + sendError(message.id, -32802, "persisted checkpoint rejected"); + return; + } + if (retry > 0) { + retry -= 1; + sendError(message.id, -32802, "fixture index is not ready"); + if (retrySentMarker !== undefined) fs.writeFileSync(retrySentMarker, "sent"); + return; + } + if (contentModified > 0) { + contentModified -= 1; + sendError(message.id, -32801, "fixture content changed"); + return; + } + sequence += 1; + const base = message.params?.checkpoint?.generation ?? message.params?.knownGeneration; + const result = snapshot(base === generation ? generation : null); + if (malformed) result.producer.commit = "wrong"; + send({ jsonrpc: "2.0", id: message.id, result }); + return; + } + if (message.method === "shutdown") { + send({ jsonrpc: "2.0", id: message.id, result: null }); + return; + } + if (message.method === "exit") finish(); +} + +function requestConfiguration() { + send({ + jsonrpc: "2.0", + id: 9001, + method: "workspace/configuration", + params: configurationWithoutItems + ? {} + : { items: [{ section: "rust-analyzer" }, { section: "rust-analyzer.cargo" }] }, + }); +} + +function snapshot(baseGeneration) { + return { + protocolVersion: 1, + schemaVersion: 1, + producer: { name: "samchon-rust-analyzer", version: "1.95.0", commit }, + universe: { + digest: universe, + target: "app", + workspaceRoots: ["."], + toolchains: ["stable"], + configurations: ["rustc-version=rustc 1.95.0 (fixture)\nhost: fixture"], + }, + sequence, + generation, + baseGeneration, + upserts: baseGeneration === null ? [shard] : [], + deletes: [], + manifest, + phases: { + semanticMillis: baseGeneration === null ? 1 : 0, + shardMillis: baseGeneration === null ? 1 : 0, + encodeMillis: 1, + totalMillis: baseGeneration === null ? 3 : 1, + cacheHit: baseGeneration !== null, + }, + }; +} + +function sendError(id, code, message) { + send({ jsonrpc: "2.0", id, error: { code, message, data: { fixture: true } } }); +} + +function send(message) { + const body = Buffer.from(JSON.stringify(message), "utf8"); + process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`); + process.stdout.write(body); +} + +function sha256(value) { + return crypto.createHash("sha256").update(canonical(value)).digest("hex"); +} + +function canonical(value) { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + return `{${Object.keys(value) + .sort(compare) + .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`) + .join(",")}}`; +} + +function compare(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function finish() { + if (marker !== undefined) fs.writeFileSync(marker, "closed"); + process.exit(process.exitCode ?? 0); +} diff --git a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs index 0ed31cae..b974e486 100644 --- a/tests/test-graph/src/internal/fake-ttscgraph-server.cjs +++ b/tests/test-graph/src/internal/fake-ttscgraph-server.cjs @@ -5,8 +5,17 @@ const readline = require("node:readline"); const args = process.argv.slice(2); const cwdIndex = args.indexOf("--cwd"); -const project = cwdIndex === -1 ? process.cwd() : path.resolve(args[cwdIndex + 1]); -const invalidMode = args.find((arg) => arg.startsWith("--invalid")); +const requestedProject = + cwdIndex === -1 ? process.cwd() : path.resolve(args[cwdIndex + 1]); +const project = args.includes("--canonical-project") + ? fs.realpathSync.native(requestedProject) + : requestedProject; +const nativeInvalidMode = args.find((arg) => + arg.startsWith("--native-invalid"), +); +const invalidMode = args.find( + (arg) => arg.startsWith("--invalid") && !arg.startsWith("--native-invalid"), +); const markerArg = args.find((arg) => arg.startsWith("--marker=")); const marker = markerArg?.slice("--marker=".length); const stdinClosedMarkerArg = args.find((arg) => @@ -17,6 +26,8 @@ const stdinClosedMarker = stdinClosedMarkerArg?.slice( ); const requestLogArg = args.find((arg) => arg.startsWith("--request-log=")); const requestLog = requestLogArg?.slice("--request-log=".length); +const nativeLogArg = args.find((arg) => arg.startsWith("--native-log=")); +const nativeLog = nativeLogArg?.slice("--native-log=".length); // Stands in for a producer that speaks a protocol this client refuses, so the // pin can be proved without shipping a second fake. const protocolArg = args.find((arg) => arg.startsWith("--protocol=")); @@ -28,6 +39,7 @@ const dropped = dropArg?.slice("--drop-capability=".length); // Moves the build universe under an `incremental` label — a producer claiming it // reused a program whose own inputs say it could not have. const universeDrift = args.includes("--universe-drift"); +const universeReload = args.includes("--universe-reload"); // Transport- and process-level fault injection. These stand in for the wire // conditions a well-formed producer never emits but a real one can: a process // that dies mid-serve, a stream chunked or blank-padded by the OS, a line that @@ -71,7 +83,11 @@ const envelopeCapabilityMismatch = args.includes( // the protocol, source-manifest, and lifecycle fixtures below untouched. const conformance = args.includes("--conformance"); const conformanceHeuristic = args.includes("--conformance-heuristic"); +const phaseTrace = args.includes("--phase-trace"); +const coordinateEscape = args.includes("--native-coordinate-escape"); let requests = 0; +let nativeState; +let nativeBase; const CAPABILITIES = [ "universe", @@ -84,12 +100,28 @@ if (duplicateCapability) CAPABILITIES.push(CAPABILITIES[0]); // Every workspace and bundled file the fake program loaded. The manifest must // cover every file the nodes below name, because that is what the client checks. -const WORKSPACE_FILES = ["src/index.ts", "src/core/order.ts", "src/empty.ts"]; +const WORKSPACE_FILES = [ + "src/index.ts", + "src/core/order.ts", + "src/empty.ts", + ...(coordinateEscape ? ["src/a&b.ts"] : []), +]; const BUNDLED_FILES = ["bundled:///libs/lib.es2015.collection.d.ts"]; const digestOf = (text) => crypto.createHash("sha256").update(text).digest("hex"); +const goJSON = (value) => + JSON.stringify(value).replace(/[<>&\u2028\u2029]/gu, (character) => { + if (character === "<") return "\\u003c"; + if (character === ">") return "\\u003e"; + if (character === "&") return "\\u0026"; + return character === "\u2028" ? "\\u2028" : "\\u2029"; + }); + +const compareUtf8 = (left, right) => + Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); + const readProjectFile = (rel) => { try { return fs.readFileSync(path.join(project, rel), "utf8"); @@ -107,19 +139,25 @@ const readProjectFile = (rel) => { * this fake needs the client to go looking on disk, and a file the client cannot * read still has a perfectly well-defined digest here. */ -const manifest = (drift) => +const manifest = (semantic) => [...WORKSPACE_FILES, ...BUNDLED_FILES].map((file) => { if (BUNDLED_FILES.includes(file)) { return { file, - checkerDigest: digestOf(`${file}:checker${drift ?? ""}`), + checkerDigest: digestOf(`${file}:checker`), diskDigest: "", }; } const text = readProjectFile(file); return { file, - checkerDigest: digestOf(text ?? `absent:${file}${drift ?? ""}`), + checkerDigest: digestOf( + `${text ?? `absent:${file}`}${ + file === "src/core/order.ts" && semantic !== "first" + ? `:program:${semantic}` + : "" + }`, + ), diskDigest: dropped === "diskDigests" || text === undefined ? "" : digestOf(text), }; @@ -135,7 +173,7 @@ const universe = (drift) => ({ roots: WORKSPACE_FILES.map((file) => ({ config: "tsconfig.json", file })), }); -const provenance = (drift) => ({ +const provenance = (semantic, drift) => ({ schemaVersion: 6, capabilities: CAPABILITIES, producer: { @@ -144,13 +182,13 @@ const provenance = (drift) => ({ typescript: "5.9.0", }, universe: universe(drift), - sources: manifest(drift), + sources: manifest(semantic), }); const graph = (name, options = {}) => ({ project, tsconfig: "tsconfig.json", - provenance: provenance(options.drift), + provenance: provenance(name, options.drift), diagnostics: dropped === "diagnostics" ? [] @@ -201,6 +239,17 @@ const graph = (name, options = {}) => ({ file: "src/empty.ts", external: false, }, + ...(coordinateEscape + ? [ + { + id: "src/a&b.ts#src/a&b.ts:module", + kind: "module", + name: "src/a&b.ts", + file: "src/a&b.ts", + external: false, + }, + ] + : []), { id: "bundled:///libs/lib.es2015.collection.d.ts#Map:interface", kind: "interface", @@ -222,6 +271,374 @@ const graph = (name, options = {}) => ({ ], }); +/** Convert the fake compiler document into the same native shards as ttscgraph. */ +function nativeSnapshot(dump) { + nativeBase = nativeState; + const shards = new Map(); + const nodeFiles = new Map(dump.nodes.map((node) => [node.id, node.file])); + const sourceOccurrences = new Map(); + const nativeProvenance = normalizeNativeProvenance(dump.provenance); + // ttsc binds shard identities to SHA-256(Go JSON(normalized Universe)). + // This is deliberately not the graph protocol's length-prefixed universe + // fingerprint, which is a separate downstream identity. + const producerUniverse = digestOf(goJSON(nativeProvenance.universe)); + const coordinates = (...values) => + goJSON([ + 1, + nativeProvenance.producer.tool, + nativeProvenance.producer.version, + nativeProvenance.producer.typescript, + dump.tsconfig, + producerUniverse, + ...values, + ]); + for (const source of nativeProvenance.sources) { + const occurrence = sourceOccurrences.get(source.file) ?? 0; + sourceOccurrences.set(source.file, occurrence + 1); + const prefix = source.file.startsWith("bundled:///") ? "2" : "1"; + const key = `${prefix}:source:${coordinates( + source.file, + source.checkerDigest, + source.diskDigest, + digestOf(`resolution:${source.file}`), + ...(occurrence === 0 ? [] : [occurrence]), + )}`; + shards.set(key, { + key, + source, + nodes: dump.nodes.filter( + (node) => !node.external && node.file === source.file, + ), + edges: dump.edges.filter( + (edge) => nodeFiles.get(edge.from) === source.file, + ), + diagnostics: dump.diagnostics.filter( + (diagnostic) => diagnostic.file === source.file, + ), + }); + } + for (const config of nativeProvenance.universe.configs) { + const key = `3:config:${coordinates(config.file, config.digest)}`; + shards.set(key, { + key, + config, + nodes: [], + edges: [], + diagnostics: dump.diagnostics.filter( + (diagnostic) => diagnostic.file === config.file, + ), + }); + } + const externalKey = `0:external:${coordinates("external")}`; + shards.set(externalKey, { + key: externalKey, + nodes: dump.nodes.filter((node) => node.external), + edges: [], + diagnostics: [], + }); + const metadataKey = `0:metadata:${coordinates("metadata")}`; + const inputFiles = new Set([ + ...nativeProvenance.sources.map((source) => source.file), + ...nativeProvenance.universe.configs.map((config) => config.file), + ]); + shards.set(metadataKey, { + key: metadataKey, + nodes: [], + edges: [], + diagnostics: dump.diagnostics.filter( + (diagnostic) => + diagnostic.file === "" || !inputFiles.has(diagnostic.file), + ), + }); + const committed = new Map( + [...shards].map(([key, shard]) => [ + key, + { digest: digestOf(goJSON(shard)), shard }, + ]), + ); + const manifest = [...committed] + .sort(([left], [right]) => compareUtf8(left, right)) + .map(([key, value]) => ({ key, digest: value.digest })); + const sequence = (nativeState?.sequence ?? 0) + 1; + const transaction = { + protocolVersion: 1, + schemaVersion: nativeProvenance.schemaVersion, + project: dump.project, + tsconfig: dump.tsconfig, + producer: nativeProvenance.producer, + capabilities: nativeProvenance.capabilities, + universe: nativeProvenance.universe, + sequence, + generation: digestOf( + goJSON({ + tsconfig: dump.tsconfig, + producer: nativeProvenance.producer, + capabilities: nativeProvenance.capabilities, + universe: nativeProvenance.universe, + manifest, + }), + ), + ...(nativeState === undefined + ? {} + : { + baseSequence: nativeState.sequence, + baseGeneration: nativeState.generation, + }), + upserts: [...committed] + .filter( + ([key, value]) => nativeState?.shards.get(key)?.digest !== value.digest, + ) + .map(([, value]) => ({ digest: value.digest, shard: value.shard })), + deletes: + nativeState === undefined + ? [] + : [...nativeState.shards.keys()].filter((key) => !committed.has(key)), + manifest, + }; + nativeState = { + sequence, + generation: transaction.generation, + shards: committed, + }; + return transaction; +} + +function normalizeNativeProvenance(provenance) { + return { + ...provenance, + capabilities: [...provenance.capabilities], + universe: { + configs: [...provenance.universe.configs].sort((left, right) => + compareUtf8(left.file, right.file), + ), + roots: [...provenance.universe.roots].sort( + (left, right) => + compareUtf8(left.config, right.config) || + compareUtf8(left.file, right.file), + ), + }, + sources: [...provenance.sources].sort((left, right) => + compareUtf8(left.file, right.file), + ), + }; +} + +function resignNativeGeneration(snapshot) { + snapshot.generation = digestOf( + goJSON({ + tsconfig: snapshot.tsconfig, + producer: snapshot.producer, + capabilities: snapshot.capabilities, + universe: snapshot.universe, + manifest: snapshot.manifest, + }), + ); +} + +function resignCompleteNativeSnapshot(snapshot) { + snapshot.upserts.forEach((upsert) => { + upsert.digest = digestOf(goJSON(upsert.shard)); + }); + snapshot.manifest = snapshot.upserts + .map((upsert) => ({ + key: upsert.shard.key, + digest: upsert.digest, + })) + .sort((left, right) => compareUtf8(left.key, right.key)); + resignNativeGeneration(snapshot); +} + +function graphUniverseFingerprint(snapshot) { + const hash = crypto.createHash("sha256"); + const push = (text) => hash.update(`${String(text.length)}:${text}`); + push("configs"); + for (const config of snapshot.universe.configs) { + push(config.file); + push(config.digest); + } + push("roots"); + for (const root of snapshot.universe.roots) { + push(root.config); + push(root.file); + } + return hash.digest("hex"); +} + +function corruptNativeSnapshot(snapshot, mode) { + const source = (file) => + snapshot.upserts.find((upsert) => upsert.shard.source?.file === file); + const config = () => + snapshot.upserts.find((upsert) => upsert.shard.config !== undefined); + const external = () => + snapshot.upserts.find((upsert) => upsert.shard.key.startsWith("0:external:")); + const metadata = () => + snapshot.upserts.find((upsert) => upsert.shard.key.startsWith("0:metadata:")); + + if (mode === "--native-invalid-digest" || mode === "--native-invalid-digest-third") { + snapshot.upserts[0].digest = "0".repeat(64); + } else if (mode === "--native-invalid-manifest") { + snapshot.manifest.push({ ...snapshot.manifest[0] }); + } else if (mode === "--native-invalid-base" || mode === "--native-invalid-base-third") { + snapshot.baseSequence = 1; + snapshot.baseGeneration = "0".repeat(64); + } else if (mode === "--native-invalid-protocol") { + snapshot.protocolVersion = 2; + } else if (mode === "--native-invalid-schema") { + snapshot.schemaVersion = 4; + } else if (mode === "--native-invalid-sequence-zero") { + snapshot.sequence = 0; + } else if (mode === "--native-invalid-sequence-fraction") { + snapshot.sequence = 1.5; + } else if (mode === "--native-invalid-generation-format") { + snapshot.generation = "invalid"; + } else if (mode === "--native-invalid-generation") { + snapshot.generation = "0".repeat(64); + } else if (mode === "--native-invalid-initial-sequence") { + snapshot.sequence = 2; + } else if (mode === "--native-invalid-base-sequence-only") { + snapshot.baseSequence = 1; + } else if (mode === "--native-invalid-base-generation-only") { + snapshot.baseGeneration = "0".repeat(64); + } else if (mode === "--native-invalid-base-sequence-type") { + snapshot.baseSequence = "one"; + snapshot.baseGeneration = "0".repeat(64); + } else if (mode === "--native-invalid-project-third") { + snapshot.project = path.join(snapshot.project, "other"); + } else if (mode === "--native-invalid-tsconfig-third") { + snapshot.tsconfig = "other-tsconfig.json"; + } else if (mode === "--native-invalid-delete-unknown-third") { + snapshot.deletes.push("missing-shard"); + } else if (mode === "--native-invalid-delete-duplicate-third") { + snapshot.deletes.push(snapshot.manifest[0].key, snapshot.manifest[0].key); + } else if (mode === "--native-invalid-upsert-duplicate-third") { + snapshot.upserts.push(structuredClone(snapshot.upserts[0])); + } else if (mode === "--native-invalid-retained-edge-target-third") { + snapshot.upserts = snapshot.upserts.filter( + (upsert) => + upsert.shard.source?.file !== "src/core/order.ts" && + upsert.shard.source?.file !== "src/index.ts", + ); + snapshot.manifest = [...nativeBase.shards] + .filter( + ([, value]) => value.shard.source?.file !== "src/core/order.ts", + ) + .sort(([left], [right]) => compareUtf8(left, right)) + .map(([key, value]) => ({ key, digest: value.digest })); + snapshot.deletes = [...nativeBase.shards] + .filter( + ([, value]) => value.shard.source?.file === "src/core/order.ts", + ) + .map(([key]) => key); + resignNativeGeneration(snapshot); + } else if (mode === "--native-invalid-key-empty") { + snapshot.upserts[0].shard.key = ""; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-key-nul") { + snapshot.upserts[0].shard.key = "bad\0key"; + resignCompleteNativeSnapshot(snapshot); + } else if ( + mode === "--native-invalid-reserved-coverage" || + mode === "--native-invalid-reserved-coverage-alternate" + ) { + snapshot.upserts[0].shard.key = + mode === "--native-invalid-reserved-coverage" + ? `0:coverage:${JSON.stringify([ + 1, + "ttscgraph", + snapshot.producer.version, + snapshot.producer.typescript, + "typescript", + snapshot.tsconfig, + graphUniverseFingerprint(snapshot), + ])}` + : "0:coverage:foreign-native-shard"; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-two-input-kinds") { + source("src/empty.ts").shard.config = structuredClone( + snapshot.universe.configs[0], + ); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-duplicate-source") { + const duplicate = source("src/empty.ts"); + duplicate.shard.source.file = "src/index.ts"; + duplicate.shard.nodes.forEach((node) => { + node.file = "src/index.ts"; + }); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-duplicate-config") { + const duplicate = structuredClone(config()); + duplicate.shard.key += ":duplicate"; + snapshot.upserts.push(duplicate); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-config-facts") { + config().shard.nodes.push(structuredClone(external().shard.nodes[0])); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-nonsource-edges") { + metadata().shard.edges.push(structuredClone(source("src/index.ts").shard.edges[0])); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-source-external-node") { + source("src/core/order.ts").shard.nodes[0].external = true; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-source-foreign-node") { + source("src/core/order.ts").shard.nodes[0].file = "src/index.ts"; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-external-local-node") { + external().shard.nodes[0].external = false; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-duplicate-node") { + metadata().shard.nodes.push(structuredClone(external().shard.nodes[0])); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-local-duplicate-node") { + external().shard.nodes.push(structuredClone(external().shard.nodes[0])); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-source-diagnostic") { + source("src/core/order.ts").shard.diagnostics[0].file = "src/index.ts"; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-config-diagnostic") { + config().shard.diagnostics.push({ file: "src/index.ts" }); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-metadata-diagnostic") { + metadata().shard.diagnostics.push({ file: "src/index.ts" }); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-edge-owner") { + source("src/index.ts").shard.edges[0].from = + source("src/core/order.ts").shard.nodes[0].id; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-config-coverage") { + snapshot.upserts = snapshot.upserts.filter( + (upsert) => upsert.shard.config === undefined, + ); + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-config-digest") { + config().shard.config = { + ...config().shard.config, + digest: "0".repeat(64), + }; + resignCompleteNativeSnapshot(snapshot); + } else if (mode === "--native-invalid-manifest-sort") { + snapshot.manifest.reverse(); + resignNativeGeneration(snapshot); + } else if (mode === "--native-invalid-manifest-entry") { + snapshot.manifest[0].digest = "0".repeat(64); + resignNativeGeneration(snapshot); + } else if (mode === "--native-invalid-manifest-digest-format") { + snapshot.manifest[0].digest = "invalid"; + } else if (mode === "--native-invalid-producer-array") { + snapshot.producer = []; + } else if (mode === "--native-invalid-capabilities-array") { + snapshot.capabilities = {}; + } else if (mode === "--native-invalid-project-string") { + snapshot.project = 1; + } else if (mode === "--native-invalid-nodes-array") { + snapshot.upserts[0].shard.nodes = {}; + } else if (mode === "--native-invalid-node-boolean") { + source("src/core/order.ts").shard.nodes[0].external = "false"; + resignCompleteNativeSnapshot(snapshot); + } else { + throw new Error(`unknown native invalid mode: ${mode}`); + } +} + function conformanceNodes() { const ranges = conformanceRanges(); const nodes = [ @@ -389,6 +806,16 @@ input.on("line", (line) => { requests += 1; if (requestLog !== undefined) fs.writeFileSync(requestLog, `${requests}\n`); if (hangRequests) return; + if (request.graphSnapshotVersion !== 1) { + emit( + frame(request.id, { + changed: false, + mode: "error", + error: "graph snapshot protocol v1 was not requested", + }), + ); + return; + } let response; if (firstUnchanged) { // A first answer that reuses a snapshot that does not exist yet. @@ -435,12 +862,16 @@ input.on("line", (line) => { } else { throw new Error(`unknown invalid mode: ${invalidMode}`); } - response = frame(request.id, { changed: true, mode: "initial", dump }); + response = frame(request.id, { + changed: true, + mode: "initial", + snapshot: nativeSnapshot(dump), + }); } else if (requests === 1) { response = frame(request.id, { changed: true, mode: "initial", - dump: graph("first"), + snapshot: nativeSnapshot(graph("first")), }); } else if (requests === 2) { response = frame(request.id, { changed: false, mode: "unchanged" }); @@ -448,8 +879,14 @@ input.on("line", (line) => { response = frame(request.id, { changed: true, mode: "incremental", - dump: graph("second", universeDrift ? { drift: "moved" } : {}), + snapshot: nativeSnapshot( + graph( + "second", + universeDrift || universeReload ? { drift: "moved" } : {}, + ), + ), }); + if (universeReload) response.mode = "reload"; } else { response = frame(request.id, { changed: false, @@ -457,6 +894,29 @@ input.on("line", (line) => { error: "synthetic failure", }); } + if ( + nativeInvalidMode !== undefined && + response.snapshot !== undefined && + (nativeInvalidMode.endsWith("-third") ? requests === 3 : requests === 1) + ) { + if (nativeInvalidMode === "--native-invalid-snapshot-string") { + response.snapshot = "invalid"; + } else if (nativeInvalidMode === "--native-invalid-snapshot-null") { + response.snapshot = null; + } else corruptNativeSnapshot(response.snapshot, nativeInvalidMode); + } + if (nativeLog !== undefined && response.snapshot !== undefined) { + fs.appendFileSync(nativeLog, `${JSON.stringify(response.snapshot)}\n`); + } + if (phaseTrace) { + process.stderr.write( + "@samchon/graph: ttscgraph-phase C:\\private\\spoof.ts\n", + ); + process.stderr.write( + `@samchon/graph: ttscgraph-phase owner=producer request=${String(request.id)}` + + ` mode=${response.mode} phase=shard-export durationMs=1.000\n`, + ); + } emit(response); if (closeStdinAfterFirst && requests === 1) { input.close();