diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 86fd4e269..4b7d5a4ab 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -140,6 +140,7 @@ jobs:
npm test
npm run package
unzip -l vscode-sharpts-*.vsix | grep 'extension/bin/server/SharpTS.DebugAdapter.dll'
+ unzip -l vscode-sharpts-*.vsix | grep 'extension/bin/server/SharpTS.LanguageServer.dll'
# Discover and type-check/compile every cross-runtime workload through the
# real runner. This catches repository moves and declaration drift without
@@ -207,6 +208,59 @@ jobs:
shell: pwsh
run: ./scripts/test-packaged-sdk.ps1
+ editor-contract:
+ needs: workflow-policy
+ if: needs.workflow-policy.outputs.mode == 'full'
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-24.04, windows-2025]
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Setup .NET
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ global-json-file: global.json
+ - name: Setup pinned Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: .node-version
+ cache: npm
+ cache-dependency-path: tools/formatter-interop/package-lock.json
+ - name: Acquire integration dependencies
+ shell: pwsh
+ run: |
+ dotnet restore src/SharpTS.LanguageServer/SharpTS.LanguageServer.csproj
+ if ($LASTEXITCODE -ne 0) { throw 'Language-server restore failed.' }
+ dotnet restore tests/fixtures/FormatterInterop/SharpTS.FormatterInteropEvidence.csproj
+ if ($LASTEXITCODE -ne 0) { throw 'Formatter evidence restore failed.' }
+ npm ci --prefix tools/formatter-interop --ignore-scripts --no-audit --no-fund
+ if ($LASTEXITCODE -ne 0) { throw 'Pinned formatter acquisition failed.' }
+ timeout-minutes: 5
+ - name: Verify protocol, formatter and installed tool contracts
+ shell: pwsh
+ run: ./scripts/test-editor-contract.ps1 -NoRestore -NoFormatterInstall
+ timeout-minutes: 25
+ - name: Upload editor contract evidence
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: editor-contract-${{ matrix.os }}
+ if-no-files-found: ignore
+ path: |
+ artifacts/editor-contract/
+ artifacts/analysis-smoke/
+ artifacts/semantic-hover-smoke/
+ artifacts/semantic-completion-smoke/
+ artifacts/semantic-signature-smoke/
+ artifacts/member-reference-smoke/
+ artifacts/private-rename-smoke/
+ retention-days: 7
+
standalone:
needs: workflow-policy
if: needs.workflow-policy.outputs.mode == 'full'
@@ -588,7 +642,7 @@ jobs:
gate:
name: Gate
if: always()
- needs: [workflow-policy, build, typescript-conformance, standalone, dap-macos-smoke, aot-ratchet, native-aot-compile-smoke, lightweight-validation]
+ needs: [workflow-policy, build, typescript-conformance, editor-contract, standalone, dap-macos-smoke, aot-ratchet, native-aot-compile-smoke, lightweight-validation]
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
@@ -603,7 +657,7 @@ jobs:
throw "CI change classification or workflow policy did not pass."
}
- $heavy = @('build', 'typescript-conformance', 'standalone', 'dap-macos-smoke', 'aot-ratchet', 'native-aot-compile-smoke')
+ $heavy = @('build', 'typescript-conformance', 'editor-contract', 'standalone', 'dap-macos-smoke', 'aot-ratchet', 'native-aot-compile-smoke')
if ($env:CHANGE_MODE -eq 'full') {
$bad = @($heavy | Where-Object { $needs.PSObject.Properties[$_].Value.result -ne 'success' })
if ($needs.'lightweight-validation'.result -ne 'skipped') {
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 75d4ce3d3..e67e945e1 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -113,6 +113,159 @@ User code imports only public specifiers. `primitive:` modules are private imple
The user-facing declaration, interpreter export, and compiled emitter for a built-in must describe
the same surface. See [`src/SharpTS/stdlib/CONTRIBUTING.md`](src/SharpTS/stdlib/CONTRIBUTING.md).
+## Editor analysis
+
+`SharpTS.LanguageServer.Services.SemanticAnalysisService` owns completed editor analyses.
+`DocumentStore.TryCapture` supplies one immutable open-buffer capture. The analysis key includes
+that capture's versions/text, workspace roots, and service generation; each build records exact
+compiler filesystem reads, probes, and inventories through the scoped `CompilerFileSystem` seam.
+This includes negative import/configuration probes and disconnected configured roots, since a
+closed root can become a reverse importer. Physical validation and CLR metadata generation checks
+run before publication, reuse, and the final handler response. Lifecycle and file/folder
+notifications invalidate the service and cancel obsolete builds.
+
+A completed `AnalysisSnapshot` owns source documents, tokens, recovered parser output, a private
+TypeMap, frozen bindings/member origins, diagnostics, options, and graph/completeness facts. No checker or resolver
+escapes the builder. Published ASTs are read-only inputs and must never be checked again. Embedded
+standard-library ASTs remain shared read-only compiler inputs. Cursor-specific recovery must use
+separate artifacts keyed by caret/query context and policy, never mutate a base snapshot.
+Recovered syntax and partial semantics are explicit; recovered configured roots keep lexical
+rename completeness false. Workspace expansion can reuse an already checked source component
+without inserting a closed declaration into the open-buffer overlay.
+
+Editor parses opt into a flat, document-owned `EditorSyntaxIndex` over the existing AST references.
+Its immutable ranges distinguish written names/expressions/types, grouping/header/body views,
+source-equivalent replacements, generated syntax, and recovered syntax. Invocation metadata
+captures the actual parentheses and top-level commas during parsing. Lookup uses deterministic
+range/role ordering; TSX lowering retains original source ownership without turning generated
+factory calls into written invocations. Ordinary compiler parsing keeps this index disabled.
+The [syntax coverage table](docs/editor-syntax-coverage.md) defines supported views and limits.
+
+Editor checking opts into a generation-owned `MemberIndex`, independent of the lexical
+`BindingIndex`. Reference-equal source class owners and captured documents identify canonical
+instance/static/private member groups. Actual checker declaration IDs alias that source owner
+across preparatory passes; generic instantiation and inherited lookup retain its origin. Overloads and
+getter/setter pairs share their source group. Parameter-properties retain separate lexical and
+property identities. Lookup hooks record the member selected by the checker, including operation
+facts and finite candidate sets. An aggregate is authoritative only when every required selection
+is supported and agrees on one origin. Structural, dynamic, CLR/built-in and index-signature
+fallbacks do not acquire fabricated source identities.
+
+`FrozenMemberIndex` copies completed identities and occurrences into the same analysis snapshot,
+without retaining the checker. Ordinary compiler checking leaves member capture disabled.
+Current compound/logical and literal-index paths that fall back to `any` without selecting a
+named source member remain unavailable; checked update operands can retain a proven read without
+claiming write proof. This metadata does not change checker diagnostics or class compatibility.
+Every new member identity denies rename. Lexical parameter-property bindings also deny rename,
+independently of graph completeness, until lexical and property edits can be coordinated.
+The [member provenance contract](docs/editor-member-provenance.md) records supported operations
+and checker paths that currently cannot establish a source origin.
+
+Full-mode `DefinitionService` selects lexical bindings first and only falls back to complete,
+unambiguous frozen member origins when no lexical facet is selected. It projects canonical
+declaration names through each target document's own line index, deduplicates full path/span
+locations and orders results deterministically. Known definitions do not require an exhaustive
+workspace graph. The existing request guard and analysis validation still refuse stale results.
+
+Full-mode member references use the same lexical-first selection, then join fresh frozen member
+groups across configured projects using normalized declaration paths/full name spans and exact
+member facets. They require complete initialized workspace discovery before collecting any new
+member locations, project each through its own captured source and deduplicate full spans. Graph
+completion does not prove unsupported or unvisited member uses. Generation-local IDs remain
+within one snapshot, and member reference results never grant general rename eligibility.
+If a configured component's exact resolver-cache lookup misses a Windows URI drive spelling,
+the builder selects only one equivalent already-loaded physical source module. Virtual module
+names keep their existing identity rules; multiple physical candidates make discovery incomplete.
+
+Full-mode `PrivateRenameService` uses a separate snapshot-local private-domain proof. The frozen
+member index audits every original private token in one authoritative class span and requires
+exact checker-owned declarations/occurrences plus entered-body evidence from the editor index.
+Unresolved tokens, unvisited bodies, nested class environments, unsupported declaration groups
+and target-document parse errors refuse the complete operation. Workspace discovery completeness
+is independent of this single-file proof. The handler requires negotiated versioned document
+changes and an exact open capture, returns the entire private token/placeholder during prepare,
+and publishes one versioned document edit only after source/dependency/metadata validation.
+The general member/reference rename gate stays false; no parser/checker language expansion or
+spelling-based occurrence search is added.
+
+`WithEditorMetadata` additionally captures actual declaration/occurrence types, source-linked
+checker scopes, receiver member projections and invocation candidates/selections. Publication
+in `FrozenEditorSemanticIndex` replaces raw types with bounded TypeScript presentations and
+copies identities/collections; no type environment, mutable class or inference callback escapes.
+Failed attempts clear stale results. Recovery, holes and incomplete candidates cannot establish
+an overload winner. Source scopes preserve unavailable shadowing locals and canonical import
+identities. Projections inspect already checked types without another inference pass, and
+ordinary checking leaves capture disabled. The [semantic query contract](docs/editor-semantic-queries.md)
+defines authority, unsupported paths and presentation limits.
+
+Full-mode `SemanticHoverService` selects exact written declaration, occurrence, annotation and
+member facts from that shared snapshot. Type-use capture records existing resolution decisions;
+unresolved named fallbacks remain unavailable even when the compiler represents them as `any`.
+Bounded callable presentation uses the public overload surface retained during checking, including
+callables nested in other types. Private calls, assignments, brand checks and `super` use separate
+name/receiver facts rather than an enclosing expression's result. Existing GUI/decorator/CLR hover
+keeps precedence; full-mode CLR usage shares the checked analysis, while interop-only does not
+request ordinary semantic analysis. The handler negotiates Markdown/plain text and revalidates
+the captured state before returning its exact UTF-16 source range.
+
+Full-mode `SemanticSignatureHelpService` joins parser-owned argument lists to frozen invocation
+facts from the same checked graph. Exact signature construction, source owners, explicit type
+arguments and callee/receiver annotation proof determine candidate eligibility. Existing signature
+substitution carries that proof by reference; unresolved fallbacks cannot create displayed `any`
+parameters. The consumer preserves public candidate order and only publishes a complete checker
+selection. Parser commas and bounded signature ranges supply active parameters and negotiated
+UTF-16 label offsets. Unfinished calls use the shared fresh cursor graph; unvisited inner calls
+remain unavailable. Decorator help retains priority in both modes.
+
+`Parser.ParseForEditor` clones the source capture for bounded cursor-local missing member names
+and unfinished call/new lists, including a hidden name after `super.`. A repair gap must contain
+the caret between consumed source and the next real token. Comma-run lookahead stops beyond the
+remaining repair budget and polls cancellation. It preserves original offsets and marks holes
+and recovered delimiters as non-authoritative. Bare `this.#` lexical failure and arbitrary broken
+enclosing braces remain unavailable. The parser itself never checks or adds bindings.
+
+Semantic cursor analysis uses an opt-in exact `ModuleResolver.EditorParseTarget` to create a fresh
+target parse inside a fresh checked graph. The original lexer retains reference directives and
+JSX pragmas; ordinary configuration, resolution, imports and program/library processing continue.
+Other source modules parse normally, and embedded read-only library declarations remain on their
+existing shared path. A configured cursor build refuses default-option fallback if configuration
+loading or membership fails, or checking cannot produce a model. Published base graphs and cached
+parse-only artifacts are never checked again.
+
+Seeded builds require the same service owner, exact originating request stamp and target path,
+plus current inputs. They replay captured reads, probes and inventories before reconstructing
+the program, observe extra inputs, capture their own fresh metadata view and validate both seed
+and combined inputs before publication. Missing or mismatched seeds use a fresh configured build.
+Checked cursor entries share the ordinary admission/LRU/64 MiB estimated budget below, with an
+additional four-entry cursor cap and no ordinary document aliases. Opaque weak-table identities
+in their keys do not retain a base AST; the shared build owns its seed lease only until completion.
+
+The existing parse-only syntax cache remains independent of checked cursor graphs.
+`SemanticAnalysisService` keys those artifacts by base document identity, cursor, query and
+recovery policy. Its separate LRU retains at most 32 artifacts and 8 MiB of estimated payload
+within the combined byte budget; invalidation clears both syntax artifacts and checked analyses.
+
+Identical in-flight requests coalesce. Caller cancellation stops only that caller's wait;
+invalidation/disposal owns build cancellation. At most two builds execute and sixteen builds are
+admitted concurrently. Completed entries use LRU eviction with an eight-entry bound and a 64 MiB
+estimated source/semantic payload budget. This estimate is not a total heap bound: it excludes CLR
+assembly image ownership and approximates AST/binding overhead. Active leases survive eviction;
+cache/lease disposal releases metadata generations when their last owner finishes. Shared build
+failures remain observable on stderr even if every caller has cancelled.
+
+`AnalysisMetadataProvider` captures read-only project/manifest inputs and reference-counted
+`MetadataLoadContext` generations. Captured private assembly bytes permit replacing user DLLs
+while older requests finish; content fingerprints detect same-size/timestamp changes. XML docs
+and decorator type-name caches validate against their own content/generation. Query-time refresh
+does not run restore or load user assemblies into the execution runtime. The installed default
+framework is a fixed host input; an explicit SDK directory is a mutable observed input.
+
+Interop editor services use these metadata views. General checker CLR synthesis still follows
+the runtime registry path: routing metadata-only Types through synthesis requires metadata-safe
+attribute/primitive inspection and scoped synthesis identities. Missing custom-reference general
+symbols remain partial and acquire no guessed source binding. The shared analysis service
+conservatively invalidates on metadata changes without claiming that synthesis bridge.
+
## Interpreter architecture
The interpreter evaluates expressions to `RuntimeValue` and executes statements against a
diff --git a/STATUS.md b/STATUS.md
index 435735279..bab675924 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -114,13 +114,22 @@ See [.NET types](docs/dotnet-types.md), [.NET integration](docs/dotnet-integrati
| TypeScript-source PDBs | ✅ | Portable PDBs with source documents, sequence points, locals/scopes, and async metadata. |
| Interpreter debug adapter | ✅ | Standalone DAP tool with cooperative breakpoints, stepping, inspection, exceptions, and worker threads. |
| VS Code extension | ✅ | Diagnostics, interop IntelliSense, and separate interpreted and compiled debug commands. |
-| Standalone language server | ✅ | Diagnostics, navigation, references, safe rename domains, completion, hover, signatures, and quick fixes. |
-| General property/member rename | Deferred | Refused when workspace completeness cannot make the edit safe. |
+| Standalone language server | ✅ | Full-mode checked hover, lexical/member completion, call/new signatures, source definitions/references, lexical rename and a bounded ECMAScript `#private` rename domain; interop features in both modes. |
+| General property/member rename | Deferred | Public/protected/TypeScript-private, structural and parameter-property rename remains unavailable, including in complete workspaces. |
+| Editor formatting | ✅ | External pinned Prettier 3.9.9; SharpTS advertises no formatting provider. Real TS/TSX save checks are recorded. |
+| Editor contract validation | ✅ | Six real-stdio suites, formatter evidence and exact installed-tool checks share an Ubuntu/Windows CI aggregate. Shipping VS Code activation, native Neovim full/two-server editing and native Helix full-mode editing have separate recorded evidence. |
See the [GUI overview](docs/gui/README.md), [language server guide](docs/language-server.md), and the
[interpreted](docs/debugging-interpreter.md) and [compiled](docs/debugging-typescript.md) debugging
guides.
+For a sole TypeScript server, use `sharpts-lsp --language-features full --diagnostics all`.
+For coexistence, use `interop-only --diagnostics sharpts-only`. CLI defaults remain independently
+`full` features and `sharpts-only` diagnostics; diagnostics can change live, while feature mode is
+fixed at initialization. The [verified editor matrix](docs/language-server.md#verified-editor-arrangements)
+records the actual clients, pinned adapter and capability limits. These supported domains do not
+claim TypeScript language-service parity.
+
## 7. Conformance baselines
Both corpora use committed path-by-path baselines. Percentages below are recalculated from the
@@ -163,7 +172,7 @@ not freeze historical benchmark magnitudes.
later than `tsc` reports it.
- A compiled dynamic-index miss on a plain object can produce `null` rather than `undefined`.
- Compiled indirect `eval` cannot access compiled local variables.
-- The `Function` constructor is absent.
+- General source construction through `Function` is unsupported; only the bounded forms documented above work.
- Compiled .NET interop propagates raw CLR exceptions rather than applying the interpreter's
JavaScript error-name mapping.
- `AsyncLocalStorage.run`/`exit` do not yet forward optional trailing arguments through the
diff --git a/benchmarks/editor-analysis/EditorAnalysis.csproj b/benchmarks/editor-analysis/EditorAnalysis.csproj
new file mode 100644
index 000000000..010e614d7
--- /dev/null
+++ b/benchmarks/editor-analysis/EditorAnalysis.csproj
@@ -0,0 +1,16 @@
+
+
+ net10.0
+ Exe
+ SharpTS.Tests
+ enable
+ enable
+ latest
+ false
+ $(DefineConstants);BASELINE
+ $(NoWarn);VSTHRD002
+
+
+
+
+
diff --git a/benchmarks/editor-analysis/Program.cs b/benchmarks/editor-analysis/Program.cs
new file mode 100644
index 000000000..8aff395e6
--- /dev/null
+++ b/benchmarks/editor-analysis/Program.cs
@@ -0,0 +1,353 @@
+using System.Diagnostics;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Runtime.CompilerServices;
+using OmniSharp.Extensions.LanguageServer.Protocol;
+using OmniSharp.Extensions.LanguageServer.Protocol.Models;
+using SharpTS.LanguageServer;
+using SharpTS.LanguageServer.Conversions;
+using SharpTS.LanguageServer.Services;
+using SharpTS.TypeSystem;
+
+const int coldSamples = 11;
+const int warmSamples = 31;
+string artifactRoot = Path.GetFullPath(args[0]);
+Directory.CreateDirectory(artifactRoot);
+var reports = new List();
+foreach (string name in new[] { "small", "multi-project" })
+{
+ Fixture fixture = Fixture.Create(Path.Combine(artifactRoot, "fixtures", name), name == "multi-project");
+ // Process-local JIT and embedded resources are deliberately warmed before cache-cold samples.
+ using (var prewarm = new QuerySession(fixture))
+ for (int i = 0; i < 3; i++) await prewarm.RunAsync();
+
+ var cold = new List();
+ for (int i = 0; i < coldSamples; i++)
+ {
+ Collect();
+ using var session = new QuerySession(fixture);
+ cold.Add(await Measure(session));
+ }
+
+ var warm = new List();
+ object? validation;
+ using (var session = new QuerySession(fixture))
+ {
+ await session.RunAsync();
+ foreach (int _ in Enumerable.Range(0, warmSamples)) warm.Add(await Measure(session));
+ validation = await session.MeasureValidationAsync(warmSamples);
+ }
+ Retention retention = MeasureRetention(fixture);
+ reports.Add(new
+ {
+ name,
+ fixture.SourceFiles,
+ fixture.Projects,
+ sourceBytes = fixture.SourceBytes,
+ cold = Summarize(cold),
+ warm = Summarize(warm),
+ retention,
+ validation,
+ });
+}
+var result = new
+{
+#if BASELINE
+ implementation = args.Length > 2 ? args[2] : "df4589b7",
+#else
+ implementation = "shared-snapshots-current",
+#endif
+ timestampUtc = DateTime.UtcNow,
+ runtime = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription,
+ os = System.Runtime.InteropServices.RuntimeInformation.OSDescription,
+ architecture = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString(),
+ processorCount = Environment.ProcessorCount,
+ coldSamples,
+ warmSamples,
+ methodology = "Same definition, references(includeDeclaration=true, workspace roots), statements, full diagnostics sequence. Fresh service cache for each cold sample; repeated unchanged capture for warm samples. Process/JIT/filesystem caches warmed. Deterministic noLib/types=[] fixtures isolate source graph. Services only, no transport or CLR metadata provider. Allocation measured process-wide across async workers. Baseline CheckModules entry counter is the only archived production source change; current uses Statistics.Checks. Retained heap is noisy process-wide full-GC live heap, cache bytes are implementation estimates.",
+ reports,
+};
+string output = Path.Combine(artifactRoot, args[1]);
+await File.WriteAllTextAsync(output, JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
+Console.WriteLine(output);
+
+static async Task Measure(QuerySession session)
+{
+ long beforeCount = session.Checks;
+ long allocatedBefore = GC.GetTotalAllocatedBytes(precise: true);
+ var watch = Stopwatch.StartNew();
+ string result = await session.RunAsync();
+ watch.Stop();
+ return new(watch.Elapsed.TotalMilliseconds, GC.GetTotalAllocatedBytes(precise: true) - allocatedBefore,
+ session.Checks - beforeCount, result, session.LastFeatureTimes, session.LastFeatureAllocations);
+}
+
+static object Summarize(List samples)
+{
+ if (samples.Select(sample => sample.Result).Distinct().Count() != 1)
+ throw new InvalidOperationException("Equivalent requests changed result within a measurement phase.");
+ double[] times = samples.Select(sample => sample.Milliseconds).Order().ToArray();
+ long[] allocations = samples.Select(sample => sample.AllocatedBytes).Order().ToArray();
+ return new
+ {
+ medianMilliseconds = times[times.Length / 2],
+ p90Milliseconds = times[(int)Math.Ceiling(times.Length * .9) - 1],
+ medianAllocatedBytes = allocations[allocations.Length / 2],
+ checksPerSequence = samples.Select(sample => sample.Checks).Distinct().Order().ToArray(),
+ resultFingerprint = samples[0].Result,
+ featureMedianMilliseconds = Enumerable.Range(0, 3).Select(index =>
+ samples.Select(sample => sample.FeatureMilliseconds[index]).Order().ElementAt(samples.Count / 2)).ToArray(),
+ featureMedianAllocatedBytes = Enumerable.Range(0, 3).Select(index =>
+ samples.Select(sample => sample.FeatureAllocatedBytes[index]).Order().ElementAt(samples.Count / 2)).ToArray(),
+ samples,
+ };
+}
+
+static void Collect()
+{
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ GC.Collect();
+}
+
+[MethodImpl(MethodImplOptions.NoInlining)]
+static Retention MeasureRetention(Fixture fixture)
+{
+ using var session = new QuerySession(fixture);
+ PopulateAndDropTemporaryReferences(session);
+ Collect();
+ long withCache = GC.GetTotalMemory(forceFullCollection: true);
+ long cacheEstimate = session.CachedBytes;
+ int snapshots = session.CachedSnapshots;
+ session.ClearCaches();
+ Collect();
+ long afterClear = GC.GetTotalMemory(forceFullCollection: true);
+ GC.KeepAlive(session);
+ return new(withCache, afterClear, withCache - afterClear,
+ cacheEstimate < 0 ? null : cacheEstimate, snapshots < 0 ? null : snapshots);
+}
+
+[MethodImpl(MethodImplOptions.NoInlining)]
+static void PopulateAndDropTemporaryReferences(QuerySession session)
+{
+ // Synchronous, separate frame so completed query tasks and AST locals cannot contaminate
+ // the full-GC comparison. No lease survives this method.
+ session.RunAsync().GetAwaiter().GetResult();
+}
+
+record Sample(double Milliseconds, long AllocatedBytes, long Checks, string Result,
+ double[] FeatureMilliseconds, long[] FeatureAllocatedBytes);
+record Retention(long LiveHeapWithCacheBytes, long LiveHeapAfterCacheClearBytes,
+ long CacheRetainedHeapBytes, long? EstimatedCachedBytes, int? CachedSnapshots);
+
+sealed class QuerySession : IDisposable
+{
+ readonly Fixture _fixture;
+ readonly DefinitionService _definitions;
+ readonly ReferenceService _references;
+ readonly DiagnosticsService _diagnostics;
+ public double[] LastFeatureTimes { get; private set; } = [];
+ public long[] LastFeatureAllocations { get; private set; } = [];
+#if !BASELINE
+ readonly SemanticAnalysisService _analysis;
+#endif
+ public QuerySession(Fixture fixture)
+ {
+ _fixture = fixture;
+#if BASELINE
+ _definitions = new();
+ _references = new();
+ _diagnostics = new();
+#else
+ var workspace = new NavigationWorkspaceContext();
+ workspace.Initialize(new InitializeParams { RootUri = new Uri(fixture.Root).AbsoluteUri });
+ _analysis = new(workspace);
+ _definitions = new(_analysis);
+ _references = new(_analysis);
+ _diagnostics = new(analysis: _analysis);
+#endif
+ }
+ public long Checks =>
+#if BASELINE
+ Interlocked.Read(ref TypeChecker.BenchmarkChecks);
+#else
+ _analysis.Statistics.Checks;
+#endif
+ public long CachedBytes =>
+#if BASELINE
+ -1;
+#else
+ _analysis.Statistics.EstimatedRetainedBytes;
+#endif
+ public int CachedSnapshots =>
+#if BASELINE
+ -1;
+#else
+ _analysis.Statistics.RetainedSnapshots;
+#endif
+ public async Task RunAsync()
+ {
+ var featureTimes = new double[3];
+ var featureAllocations = new long[3];
+ long featureAllocatedBefore = GC.GetTotalAllocatedBytes(precise: true);
+ var featureWatch = Stopwatch.StartNew();
+#if BASELINE
+ var definitions = _definitions.FindDefinitions(_fixture.Path, _fixture.Text,
+ _fixture.Position, _fixture.Capture.TextOverlay);
+ featureTimes[0] = featureWatch.Elapsed.TotalMilliseconds;
+ featureAllocations[0] = GC.GetTotalAllocatedBytes(precise: true) - featureAllocatedBefore;
+ featureAllocatedBefore = GC.GetTotalAllocatedBytes(precise: true);
+ featureWatch.Restart();
+ var references = _references.FindReferenceResult(_fixture.Path, _fixture.Text,
+ _fixture.Position, true, _fixture.Capture.TextOverlay, [_fixture.Root]);
+ featureTimes[1] = featureWatch.Elapsed.TotalMilliseconds;
+ featureAllocations[1] = GC.GetTotalAllocatedBytes(precise: true) - featureAllocatedBefore;
+ featureAllocatedBefore = GC.GetTotalAllocatedBytes(precise: true);
+ featureWatch.Restart();
+ var statements = _diagnostics.GetStatements(_fixture.Capture.Document, CancellationToken.None);
+ var diagnostics = _diagnostics.Analyze(_fixture.Capture, _fixture.Capture.Document,
+ DiagnosticPublishMode.All, CancellationToken.None);
+ await Task.CompletedTask;
+#else
+ var definitionResult = await _definitions.FindDefinitionsAsync(_fixture.Capture,
+ _fixture.Position, CancellationToken.None);
+ if (!definitionResult.IsCurrent()) throw new InvalidOperationException("Stale definition");
+ var definitions = definitionResult.Locations;
+ featureTimes[0] = featureWatch.Elapsed.TotalMilliseconds;
+ featureAllocations[0] = GC.GetTotalAllocatedBytes(precise: true) - featureAllocatedBefore;
+ featureAllocatedBefore = GC.GetTotalAllocatedBytes(precise: true);
+ featureWatch.Restart();
+ var references = await _references.FindReferenceResultAsync(_fixture.Capture,
+ _fixture.Position, true, [_fixture.Root], cancellationToken: CancellationToken.None);
+ if (!references.IsCurrent()) throw new InvalidOperationException("Stale references");
+ featureTimes[1] = featureWatch.Elapsed.TotalMilliseconds;
+ featureAllocations[1] = GC.GetTotalAllocatedBytes(precise: true) - featureAllocatedBefore;
+ featureAllocatedBefore = GC.GetTotalAllocatedBytes(precise: true);
+ featureWatch.Restart();
+ var statements = await _diagnostics.GetStatementsAsync(_fixture.Capture,
+ DiagnosticPublishMode.All, CancellationToken.None);
+ var diagnosticsResult = await _diagnostics.AnalyzeResultAsync(_fixture.Capture,
+ _fixture.Capture.Document, DiagnosticPublishMode.All, CancellationToken.None);
+ if (!diagnosticsResult.IsCurrent(CancellationToken.None)) throw new InvalidOperationException("Stale diagnostics");
+ var diagnostics = diagnosticsResult.Diagnostics;
+#endif
+ featureTimes[2] = featureWatch.Elapsed.TotalMilliseconds;
+ featureAllocations[2] = GC.GetTotalAllocatedBytes(precise: true) - featureAllocatedBefore;
+ LastFeatureTimes = featureTimes;
+ LastFeatureAllocations = featureAllocations;
+ if (definitions.Count != 1 || references.Locations.Count < 2 || !references.IsComplete ||
+ diagnostics.Count != 0 || statements.Count < 2)
+ throw new InvalidOperationException($"Unexpected query output: defs={definitions.Count}; refs={references.Locations.Count}; complete={references.IsComplete}; diagnostics={diagnostics.Count}; stmts={statements.Count}; " + string.Join(" | ", diagnostics.Select(diagnostic => diagnostic.Message)));
+ string RelativeLocation(Location location) => string.Join(":", Path.GetRelativePath(_fixture.Root,
+ location.Uri.GetFileSystemPath()).Replace('\\', '/'), location.Range.Start.Line,
+ location.Range.Start.Character, location.Range.End.Line, location.Range.End.Character);
+ string output = string.Join("|", definitions.Select(RelativeLocation)) + ";" +
+ string.Join("|", references.Locations.Select(RelativeLocation)) + ";" + references.IsComplete + ";" +
+ statements.Count + ";" + diagnostics.Count;
+ return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(output)));
+ }
+ public void Dispose()
+ {
+#if !BASELINE
+ _definitions.Dispose(); _references.Dispose(); _diagnostics.Dispose(); _analysis.Dispose();
+#endif
+ }
+ public void ClearCaches()
+ {
+ _diagnostics.Invalidate(_fixture.Path);
+#if !BASELINE
+ _analysis.InvalidateAll();
+#endif
+ }
+ public async Task MeasureValidationAsync(int samples)
+ {
+#if BASELINE
+ await Task.CompletedTask;
+ return null;
+#else
+ using var lease = await _analysis.GetDocumentAsync(_fixture.Capture);
+ if (lease is null) throw new InvalidOperationException("Missing validation capture");
+ int offset = lease.Model.Document.Lines.ToOffset(
+ (int)_fixture.Position.Line + 1, (int)_fixture.Position.Character + 1);
+ string anchorPath = lease.Model.Bindings.FindSymbols(lease.Model.Document, offset)
+ .SelectMany(symbol => symbol.Declarations).First().Document.Path;
+ using var workspace = await _analysis.GetWorkspaceAsync(_fixture.Capture, anchorPath,
+ lease, [_fixture.Root], CancellationToken.None);
+ if (workspace is null) throw new InvalidOperationException("Missing workspace validation capture");
+ object MeasureProof(AnalysisLease captured)
+ {
+ var durations = new List();
+ var allocations = new List();
+ for (int i = 0; i < samples; i++)
+ {
+ long before = GC.GetTotalAllocatedBytes(precise: true);
+ var watch = Stopwatch.StartNew();
+ if (!captured.Validation.IsCurrent()) throw new InvalidOperationException("Stale validation sample");
+ watch.Stop();
+ durations.Add(watch.Elapsed.TotalMilliseconds);
+ allocations.Add(GC.GetTotalAllocatedBytes(precise: true) - before);
+ }
+ return new
+ {
+ inputs = captured.Inputs.Count,
+ observedInputEstimatedBytes = captured.Inputs.EstimatedBytes,
+ medianMilliseconds = durations.Order().ElementAt(samples / 2),
+ p90Milliseconds = durations.Order().ElementAt((int)Math.Ceiling(samples * .9) - 1),
+ medianAllocatedBytes = allocations.Order().ElementAt(samples / 2),
+ };
+ }
+ return new { document = MeasureProof(lease), workspace = MeasureProof(workspace) };
+#endif
+ }
+}
+
+sealed record Fixture(string Root, string Path, string Text, Position Position,
+ DocumentRequestSnapshot Capture, int SourceFiles, int Projects, long SourceBytes)
+{
+ public static Fixture Create(string root, bool multi)
+ {
+ Directory.CreateDirectory(root);
+ void Write(string path, string text)
+ {
+ string absolute = System.IO.Path.Combine(root, path);
+ Directory.CreateDirectory(System.IO.Path.GetDirectoryName(absolute)!);
+ if (!File.Exists(absolute) || File.ReadAllText(absolute) != text) File.WriteAllText(absolute, text);
+ }
+ const string options = "\"compilerOptions\":{\"noLib\":true,\"types\":[]}";
+ string path;
+ string text;
+ if (!multi)
+ {
+ text = "export const original = 1;\nconst result: number = original;\n";
+ path = System.IO.Path.Combine(root, "main.ts");
+ Write("main.ts", text);
+ Write("tsconfig.json", "{" + options + ",\"files\":[\"main.ts\"]}");
+ }
+ else
+ {
+ Write("tsconfig.json", "{" + options + ",\"files\":[],\"references\":[{\"path\":\"packages/lib\"},{\"path\":\"apps/one\"},{\"path\":\"apps/two\"}]}");
+ Write("packages/lib/src/dependency.ts", "export const original = 1;\n");
+ Write("packages/lib/tsconfig.json", "{" + options + ",\"include\":[\"src/**/*.ts\"]}");
+ for (int app = 1; app <= 2; app++)
+ {
+ string directory = app == 1 ? "apps/one" : "apps/two";
+ Write(directory + "/tsconfig.json", "{" + options + ",\"include\":[\"src/**/*.ts\"],\"references\":[{\"path\":\"../../packages/lib\"}]}");
+ for (int file = 0; file < 12; file++)
+ Write(directory + $"/src/importer{file}.ts", "import { original as first } from '../../../packages/lib/src/dependency';\nconst result: number = first;\n" +
+ string.Join("\n", Enumerable.Range(0, 30).Select(index => $"export function compute{index}(value: number): number {{ return value + result; }}")) + "\n");
+ }
+ path = System.IO.Path.Combine(root, "apps/one/src/importer0.ts");
+ text = File.ReadAllText(path);
+ }
+ // Match DocumentStore's canonical filesystem identities, including separator spelling.
+ path = System.IO.Path.GetFullPath(path);
+ string uri = new Uri(path).AbsoluteUri;
+ var snapshot = new DocumentSnapshot(uri, text, 1, path);
+ var capture = new DocumentRequestSnapshot(snapshot, 1,
+ new Dictionary(StringComparer.OrdinalIgnoreCase) { [path] = snapshot });
+ string[] sources = Directory.GetFiles(root, "*.ts", SearchOption.AllDirectories);
+ return new(root, path, text, new Position(1, "const result: number = ".Length + 1),
+ capture, sources.Length, multi ? 4 : 1, sources.Sum(source => new FileInfo(source).Length));
+ }
+}
diff --git a/benchmarks/editor-analysis/README.md b/benchmarks/editor-analysis/README.md
new file mode 100644
index 000000000..a10c1e42c
--- /dev/null
+++ b/benchmarks/editor-analysis/README.md
@@ -0,0 +1,24 @@
+# Checked editor analysis benchmark
+
+Optional console runner for issue #1975. It is outside the solution and ordinary test runs.
+The executable uses the `SharpTS.Tests` friend assembly name to read the LSP's internal analysis counters; it is not a test assembly or a distributed tool.
+
+Run from the repository root with .NET 10 and PowerShell:
+
+```powershell
+./benchmarks/editor-analysis/run.ps1
+# Offline replay using an already populated NuGet cache:
+./benchmarks/editor-analysis/run.ps1 -PackageCache C:/Users/you/.nuget/packages
+```
+
+The script archives the exact baseline commit `df4589b7` beneath ignored `artifacts/analysis-benchmark`, adds one `Interlocked.Increment` counter at the archived `TypeChecker.CheckModules` entry point, and builds baseline and current Release binaries separately. No baseline service behavior is reconstructed. The runner compiles against each binary set using `LibraryDirectory` and `Baseline=true` for the old API. All fixtures, archived source, intermediate files and raw samples remain ignored. No package dependency is added to the language server.
+
+Each fixture runs the same definition, references (including declarations and workspace expansion), statement retrieval, and full diagnostics sequence. The runner verifies usable results and records a source-relative fingerprint; the script refuses unequal baseline/current fingerprints. The small fixture has one source file; the multi-project fixture has 25 source files, four configs, project references and two consumers with 30 functions per file. Both explicitly set `noLib: true` and `types: []` to isolate source graph work. This measures service queries, excluding stdio transport, editor UI and CLR metadata refresh.
+
+After three process-local prewarm sequences, 11 cache-cold samples use fresh services and 31 warm samples repeat an unchanged capture. “Cold” means an empty analysis cache; JIT, embedded resources and operating-system file caches are already warm. Reports include median/p90 latency, process-wide allocated bytes (including asynchronous workers), actual whole-graph checker calls, feature timing in the order definition/references/diagnostics, and current cache estimates. Document and expanded-workspace currentness validation are measured separately with their observed-input counts and byte estimates.
+
+Retained memory is measured in a separate non-inlined synchronous frame: populate one session, release all temporary query tasks/AST references, force full GC, invalidate its caches without holding a lease, and force GC again. Reports include both absolute live-heap values and their difference. These are process-wide managed heap observations, not a strict peak or private working-set limit. Cache byte estimates are approximate source/token/type costs and do not claim to include every runtime object or CLR metadata generation.
+
+Interpret individual regressions alongside the combined sequence. The old warm diagnostics cache returned without disk/config validation; the new path must read the observed inputs before publication. Cold definition also pays capture, immutable projection, input validation and bounded-cache admission that later features reuse. The acceptance target is shared trustworthy results and zero warm whole-project checks, not zero-cost validation.
+
+The [2026-10-07 compact report](results-2026-10-07.json) records matching query fingerprints, zero warm checks, and reduced combined cold/warm latency for both fixtures. Individual cold definition latency and allocation increase: 4.60→6.73 ms / 182→237 KB for the small fixture and 33.17→44.87 ms / 10.64→13.03 MB for the multi-project fixture. This includes the new completed snapshot capture and immutable projections plus input validation; it is not a measurement of freezing alone. Direct document validation costs 0.55 ms and 2.44 ms respectively in that run. Workspace validation costs 0.70 ms and 6.17 ms. The old warm diagnostics result was nearly free because it reused diagnostics without validating closed inputs; the current result deliberately pays this validation cost. Combined warm allocations fall from 534→117 KB and 40.53→3.32 MB, while retained cache data grows from 2.4→19.1 KB and 81.9 KB→3.42 MB to keep the reusable semantic graphs. Cache invalidation releases the measured data once requests and leases have finished.
diff --git a/benchmarks/editor-analysis/results-2026-10-07.json b/benchmarks/editor-analysis/results-2026-10-07.json
new file mode 100644
index 000000000..3f0292eb0
--- /dev/null
+++ b/benchmarks/editor-analysis/results-2026-10-07.json
@@ -0,0 +1,274 @@
+{
+ "issue": 1975,
+ "baselineCommit": "df4589b7",
+ "comparison": "Equivalent existing service queries with exact matching result fingerprints",
+ "featureOrder": [
+ "definition",
+ "references",
+ "statements+full diagnostics"
+ ],
+ "baseline": {
+ "implementation": "df4589b7",
+ "timestampUtc": "2026-10-07T05:15:11.0123089Z",
+ "runtime": ".NET 10.0.12",
+ "os": "Microsoft Windows 10.0.26220",
+ "architecture": "Arm64",
+ "processorCount": 12,
+ "coldSamples": 11,
+ "warmSamples": 31,
+ "methodology": "Same definition, references(includeDeclaration=true, workspace roots), statements, full diagnostics sequence. Fresh service cache for each cold sample; repeated unchanged capture for warm samples. Process/JIT/filesystem caches warmed. Deterministic noLib/types=[] fixtures isolate source graph. Services only, no transport or CLR metadata provider. Allocation measured process-wide across async workers. Baseline CheckModules entry counter is the only archived production source change; current uses Statistics.Checks. Retained heap is noisy process-wide full-GC live heap, cache bytes are implementation estimates.",
+ "reports": [
+ {
+ "name": "small",
+ "SourceFiles": 1,
+ "Projects": 1,
+ "sourceBytes": 60,
+ "cold": {
+ "medianMilliseconds": 18.0292,
+ "p90Milliseconds": 19.6395,
+ "medianAllocatedBytes": 724768,
+ "checksPerSequence": [
+ 4
+ ],
+ "resultFingerprint": "0D452F0C8902E0D5EEF6E72D1C67A1F9DDA7A27A35D06F31916F4E9D73EE3DBB",
+ "featureMedianMilliseconds": [
+ 4.6038,
+ 8.9513,
+ 4.5929
+ ],
+ "featureMedianAllocatedBytes": [
+ 181992,
+ 357224,
+ 181696
+ ]
+ },
+ "warm": {
+ "medianMilliseconds": 13.6526,
+ "p90Milliseconds": 14.3327,
+ "medianAllocatedBytes": 534448,
+ "checksPerSequence": [
+ 3
+ ],
+ "resultFingerprint": "0D452F0C8902E0D5EEF6E72D1C67A1F9DDA7A27A35D06F31916F4E9D73EE3DBB",
+ "featureMedianMilliseconds": [
+ 4.6065,
+ 9.042,
+ 0.0037
+ ],
+ "featureMedianAllocatedBytes": [
+ 172544,
+ 357256,
+ 792
+ ]
+ },
+ "retention": {
+ "LiveHeapWithCacheBytes": 494880,
+ "LiveHeapAfterCacheClearBytes": 492464,
+ "CacheRetainedHeapBytes": 2416,
+ "EstimatedCachedBytes": null,
+ "CachedSnapshots": null
+ },
+ "validation": null
+ },
+ {
+ "name": "multi-project",
+ "SourceFiles": 25,
+ "Projects": 4,
+ "sourceBytes": 57003,
+ "cold": {
+ "medianMilliseconds": 188.7964,
+ "p90Milliseconds": 190.9428,
+ "medianAllocatedBytes": 55990232,
+ "checksPerSequence": [
+ 6
+ ],
+ "resultFingerprint": "215F3E9CFCFAEDE2C9783683F26A6766859D588F43DE8AF99EDA41422A47ACE0",
+ "featureMedianMilliseconds": [
+ 33.1713,
+ 120.4202,
+ 34.7538
+ ],
+ "featureMedianAllocatedBytes": [
+ 10636496,
+ 34289728,
+ 10959048
+ ]
+ },
+ "warm": {
+ "medianMilliseconds": 117.6894,
+ "p90Milliseconds": 133.2203,
+ "medianAllocatedBytes": 40532368,
+ "checksPerSequence": [
+ 5
+ ],
+ "resultFingerprint": "215F3E9CFCFAEDE2C9783683F26A6766859D588F43DE8AF99EDA41422A47ACE0",
+ "featureMedianMilliseconds": [
+ 23.5061,
+ 89.0481,
+ 0.0133
+ ],
+ "featureMedianAllocatedBytes": [
+ 9652360,
+ 30768656,
+ 6392
+ ]
+ },
+ "retention": {
+ "LiveHeapWithCacheBytes": 892048,
+ "LiveHeapAfterCacheClearBytes": 810104,
+ "CacheRetainedHeapBytes": 81944,
+ "EstimatedCachedBytes": null,
+ "CachedSnapshots": null
+ },
+ "validation": null
+ }
+ ]
+ },
+ "current": {
+ "implementation": "shared-snapshots-current",
+ "timestampUtc": "2026-10-07T05:15:15.6793508Z",
+ "runtime": ".NET 10.0.12",
+ "os": "Microsoft Windows 10.0.26220",
+ "architecture": "Arm64",
+ "processorCount": 12,
+ "coldSamples": 11,
+ "warmSamples": 31,
+ "methodology": "Same definition, references(includeDeclaration=true, workspace roots), statements, full diagnostics sequence. Fresh service cache for each cold sample; repeated unchanged capture for warm samples. Process/JIT/filesystem caches warmed. Deterministic noLib/types=[] fixtures isolate source graph. Services only, no transport or CLR metadata provider. Allocation measured process-wide across async workers. Baseline CheckModules entry counter is the only archived production source change; current uses Statistics.Checks. Retained heap is noisy process-wide full-GC live heap, cache bytes are implementation estimates.",
+ "reports": [
+ {
+ "name": "small",
+ "SourceFiles": 1,
+ "Projects": 1,
+ "sourceBytes": 60,
+ "cold": {
+ "medianMilliseconds": 16.6515,
+ "p90Milliseconds": 17.2711,
+ "medianAllocatedBytes": 471216,
+ "checksPerSequence": [
+ 1
+ ],
+ "resultFingerprint": "0D452F0C8902E0D5EEF6E72D1C67A1F9DDA7A27A35D06F31916F4E9D73EE3DBB",
+ "featureMedianMilliseconds": [
+ 6.7273,
+ 8.1286,
+ 1.6929
+ ],
+ "featureMedianAllocatedBytes": [
+ 237496,
+ 196680,
+ 33256
+ ]
+ },
+ "warm": {
+ "medianMilliseconds": 5.3849,
+ "p90Milliseconds": 5.576,
+ "medianAllocatedBytes": 117080,
+ "checksPerSequence": [
+ 0
+ ],
+ "resultFingerprint": "0D452F0C8902E0D5EEF6E72D1C67A1F9DDA7A27A35D06F31916F4E9D73EE3DBB",
+ "featureMedianMilliseconds": [
+ 1.1133,
+ 2.5489,
+ 1.6735
+ ],
+ "featureMedianAllocatedBytes": [
+ 21968,
+ 58000,
+ 33256
+ ]
+ },
+ "retention": {
+ "LiveHeapWithCacheBytes": 552024,
+ "LiveHeapAfterCacheClearBytes": 532944,
+ "CacheRetainedHeapBytes": 19080,
+ "EstimatedCachedBytes": 4788,
+ "CachedSnapshots": 2
+ },
+ "validation": {
+ "document": {
+ "inputs": 14,
+ "observedInputEstimatedBytes": 546,
+ "medianMilliseconds": 0.548,
+ "p90Milliseconds": 0.6994,
+ "medianAllocatedBytes": 8728
+ },
+ "workspace": {
+ "inputs": 17,
+ "observedInputEstimatedBytes": 802,
+ "medianMilliseconds": 0.701,
+ "p90Milliseconds": 0.8896,
+ "medianAllocatedBytes": 10152
+ }
+ }
+ },
+ {
+ "name": "multi-project",
+ "SourceFiles": 25,
+ "Projects": 4,
+ "sourceBytes": 57003,
+ "cold": {
+ "medianMilliseconds": 136.1945,
+ "p90Milliseconds": 138.0312,
+ "medianAllocatedBytes": 31683360,
+ "checksPerSequence": [
+ 3
+ ],
+ "resultFingerprint": "215F3E9CFCFAEDE2C9783683F26A6766859D588F43DE8AF99EDA41422A47ACE0",
+ "featureMedianMilliseconds": [
+ 44.8743,
+ 83.8138,
+ 6.5008
+ ],
+ "featureMedianAllocatedBytes": [
+ 13033408,
+ 17762904,
+ 775576
+ ]
+ },
+ "warm": {
+ "medianMilliseconds": 21.2132,
+ "p90Milliseconds": 23.2735,
+ "medianAllocatedBytes": 3320816,
+ "checksPerSequence": [
+ 0
+ ],
+ "resultFingerprint": "215F3E9CFCFAEDE2C9783683F26A6766859D588F43DE8AF99EDA41422A47ACE0",
+ "featureMedianMilliseconds": [
+ 3.1244,
+ 12.9861,
+ 4.8504
+ ],
+ "featureMedianAllocatedBytes": [
+ 511664,
+ 1928840,
+ 775352
+ ]
+ },
+ "retention": {
+ "LiveHeapWithCacheBytes": 7846784,
+ "LiveHeapAfterCacheClearBytes": 4430816,
+ "CacheRetainedHeapBytes": 3415968,
+ "EstimatedCachedBytes": 2726372,
+ "CachedSnapshots": 2
+ },
+ "validation": {
+ "document": {
+ "inputs": 35,
+ "observedInputEstimatedBytes": 56358,
+ "medianMilliseconds": 2.4402,
+ "p90Milliseconds": 2.8914,
+ "medianAllocatedBytes": 252304
+ },
+ "workspace": {
+ "inputs": 93,
+ "observedInputEstimatedBytes": 121054,
+ "medianMilliseconds": 6.172,
+ "p90Milliseconds": 6.6782,
+ "medianAllocatedBytes": 549440
+ }
+ }
+ }
+ ]
+ }
+}
diff --git a/benchmarks/editor-analysis/run.ps1 b/benchmarks/editor-analysis/run.ps1
new file mode 100644
index 000000000..84922c737
--- /dev/null
+++ b/benchmarks/editor-analysis/run.ps1
@@ -0,0 +1,67 @@
+param(
+ [string]$BaselineCommit = 'df4589b7',
+ [string]$PackageCache = '',
+ [string]$OutputDirectory = ''
+)
+$ErrorActionPreference = 'Stop'
+$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
+if (!$OutputDirectory) { $OutputDirectory = Join-Path $repoRoot 'artifacts/analysis-benchmark' }
+$outputRoot = [IO.Path]::GetFullPath($OutputDirectory)
+New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null
+$baselineRoot = Join-Path $outputRoot ('baseline-' + $BaselineCommit)
+$baselineArchive = Join-Path $outputRoot ('baseline-' + $BaselineCommit + '.zip')
+if (!(Test-Path -LiteralPath $baselineRoot)) {
+ & git -C $repoRoot archive --format=zip "--output=$baselineArchive" $BaselineCommit
+ if ($LASTEXITCODE) { throw 'git archive failed.' }
+ Expand-Archive -LiteralPath $baselineArchive -DestinationPath $baselineRoot
+}
+
+# The archived baseline's only semantic-source change is a counter at CheckModules entry.
+$checkerPath = Join-Path $baselineRoot 'src/SharpTS/TypeSystem/TypeChecker.cs'
+$checker = [IO.File]::ReadAllText($checkerPath)
+if (!$checker.Contains('internal static long BenchmarkChecks;')) {
+ $signature = 'public TypeMap CheckModules(List modules, ModuleResolver resolver)'
+ if (($checker.Split($signature).Length - 1) -ne 1) { throw 'Expected exactly one baseline CheckModules.' }
+ $checker = $checker.Replace($signature, 'internal static long BenchmarkChecks;' + [Environment]::NewLine + ' ' + $signature)
+ $checker = [regex]::Replace($checker,
+ '(public TypeMap CheckModules\(List modules, ModuleResolver resolver\)\s*\{)',
+ '$1' + [Environment]::NewLine + ' Interlocked.Increment(ref BenchmarkChecks);')
+ [IO.File]::WriteAllText($checkerPath, $checker)
+}
+
+function Invoke-CheckedDotNet([string[]]$DotNetArguments) {
+ & dotnet @DotNetArguments
+ if ($LASTEXITCODE) { throw "dotnet failed: $($DotNetArguments[0])" }
+}
+
+$restoreOptions = @('-p:NuGetAudit=false', '-p:MinVerVersionOverride=0.0.0-benchmark')
+if ($PackageCache) {
+ # Optional offline replay against an already populated cache; never downloads into it.
+ $emptyFeed = Join-Path $outputRoot 'empty-feed'
+ New-Item -ItemType Directory -Path $emptyFeed -Force | Out-Null
+ $restoreOptions += @("-p:RestorePackagesPath=$([IO.Path]::GetFullPath($PackageCache))", '--source', $emptyFeed)
+}
+Invoke-CheckedDotNet (@('build', (Join-Path $baselineRoot 'src/SharpTS.LanguageServer/SharpTS.LanguageServer.csproj'), '-c', 'Release', '-v', 'minimal') + $restoreOptions)
+# Build current separately so both measurements use exactly the requested source state.
+Invoke-CheckedDotNet (@('build', (Join-Path $repoRoot 'src/SharpTS.LanguageServer/SharpTS.LanguageServer.csproj'), '-c', 'Release', '-v', 'minimal') + $restoreOptions)
+
+$runnerProject = Join-Path $PSScriptRoot 'EditorAnalysis.csproj'
+foreach ($implementation in @('baseline', 'current')) {
+ $libraryRoot = if ($implementation -eq 'baseline') { $baselineRoot } else { $repoRoot }
+ $libraryDirectory = Join-Path $libraryRoot 'src/SharpTS.LanguageServer/bin/Release/net10.0'
+ $runnerOptions = @('build', $runnerProject, '-c', 'Release', '-v', 'minimal',
+ "-p:LibraryDirectory=$libraryDirectory", "-p:BaseOutputPath=$outputRoot/bin/$implementation/",
+ "-p:BaseIntermediateOutputPath=$outputRoot/obj/$implementation/")
+ if ($implementation -eq 'baseline') { $runnerOptions += '-p:Baseline=true' }
+ Invoke-CheckedDotNet ($runnerOptions + $restoreOptions)
+ Invoke-CheckedDotNet @((Join-Path $outputRoot "bin/$implementation/Release/net10.0/SharpTS.Tests.dll"), $outputRoot, "$implementation.json", $BaselineCommit)
+}
+
+$before = Get-Content -LiteralPath (Join-Path $outputRoot 'baseline.json') -Raw | ConvertFrom-Json
+$after = Get-Content -LiteralPath (Join-Path $outputRoot 'current.json') -Raw | ConvertFrom-Json
+for ($index = 0; $index -lt $before.reports.Count; $index++) {
+ if ($before.reports[$index].cold.resultFingerprint -ne $after.reports[$index].cold.resultFingerprint) {
+ throw "Baseline/current result mismatch: $($before.reports[$index].name)"
+ }
+}
+Write-Output "Matching query results. Reports: $outputRoot/baseline.json and current.json"
diff --git a/benchmarks/editor-semantics/EditorSemantics.csproj b/benchmarks/editor-semantics/EditorSemantics.csproj
new file mode 100644
index 000000000..797b293c3
--- /dev/null
+++ b/benchmarks/editor-semantics/EditorSemantics.csproj
@@ -0,0 +1,14 @@
+
+
+ net10.0
+ Exe
+ SharpTS.EditorSemanticsBenchmark
+ enable
+ enable
+ false
+ $(DefineConstants);BASELINE
+
+
+
+
+
diff --git a/benchmarks/editor-semantics/Program.cs b/benchmarks/editor-semantics/Program.cs
new file mode 100644
index 000000000..26bd847d0
--- /dev/null
+++ b/benchmarks/editor-semantics/Program.cs
@@ -0,0 +1,423 @@
+using System.Collections;
+using System.Diagnostics;
+using System.Globalization;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using SharpTS.Diagnostics;
+using SharpTS.Configuration;
+using SharpTS.Modules;
+using SharpTS.Parsing;
+using SharpTS.TypeSystem;
+using TypeInfo = SharpTS.TypeSystem.TypeInfo;
+
+const int sampleCount = 31;
+VerifyNominalFingerprint();
+var reports = new List();
+foreach (bool multi in new[] { false, true })
+{
+ foreach (CheckMode mode in Enum.GetValues())
+ {
+#if BASELINE
+ if (mode != CheckMode.Ordinary) continue;
+#endif
+ int repetitions = multi ? 2 : 12;
+ Console.WriteLine($"Starting {(multi ? "25 modules" : "small")}: {mode}");
+ for (int index = 0; index < 30; index++) Check(Prepare(multi), mode);
+ var times = new List();
+ var allocations = new List();
+ Graph? lastGraph = null;
+ Published? last = null;
+ for (int sample = 0; sample < sampleCount; sample++)
+ {
+ // The checker updates modules and AST-adjacent state. Every timed check receives a
+ // fresh editor-enabled parsed graph, with all setup excluded from the counters.
+ Graph[] graphs = Enumerable.Range(0, repetitions).Select(_ => Prepare(multi)).ToArray();
+ GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ long started = Stopwatch.GetTimestamp();
+ foreach (Graph graph in graphs) last = Check(graph, mode);
+ times.Add(Stopwatch.GetElapsedTime(started).TotalMilliseconds / repetitions);
+ allocations.Add((GC.GetAllocatedBytesForCurrentThread() - before) / repetitions);
+ lastGraph = graphs[^1];
+ GC.KeepAlive(graphs);
+ }
+ times.Sort(); allocations.Sort();
+#if !BASELINE
+ if (mode == CheckMode.Editor &&
+ (last!.Editor.Occurrences.Count == 0 || last.Editor.Declarations.Count == 0 || last.Editor.Scopes.Count == 0 ||
+ !last.Editor.Invocations.Any(fact => fact.Status == EditorInvocationStatus.Selected) ||
+ !last.Editor.Invocations.Any(fact => fact.Candidates.Count > 1) ||
+ !last.Editor.Invocations.Any(fact => fact.Candidates.Any(candidate => candidate.Instantiated is not null)) ||
+ lastGraph!.QueryExpressions.Sum(probe => last.Editor.GetReceiverMembers(probe.Document, probe.Owner).Members.Count) == 0 ||
+ lastGraph.QueryScopes.Any(site => last.Editor.GetVisibleBindings(site.Document, site.Offset).Count == 0)))
+ throw new InvalidOperationException("Editor fixtures must exercise types, declarations, scopes, receiver members, overloads and instantiated selections.");
+#endif
+ var fingerprints = Fingerprints(lastGraph!, last!);
+ reports.Add(new
+ {
+ fixture = multi ? "25-module-scopes-overloads-generics" : "small",
+ mode = mode.ToString(),
+ sourceFiles = lastGraph!.Modules.Count,
+ sourceUtf16Bytes = lastGraph.Modules.Sum(module => module.Document!.Text.Length * sizeof(char)),
+ repetitionsPerSample = repetitions,
+ timedChecks = sampleCount * repetitions,
+ medianMilliseconds = times[sampleCount / 2],
+ p90Milliseconds = times[(int)Math.Ceiling(sampleCount * .9) - 1],
+ medianAllocatedBytes = allocations[sampleCount / 2],
+ retainedPublishedHeapDeltaBytes = Retained(multi, mode),
+ diagnosticCount = last!.Diagnostics.Count,
+ typedExpressions = lastGraph.Expressions.Count(expr => last.Types.Get(expr) is not null),
+ lexicalTokenCount = lastGraph.Tokens.Count,
+ memberSymbols = last.Members.Symbols.Count,
+ memberOccurrences = last.Members.Occurrences.Count,
+ memberEstimatedBytes = last.Members.EstimatedBytes,
+#if !BASELINE
+ editorFacts = last.Editor.Count,
+ editorOccurrences = last.Editor.Occurrences.Count,
+ editorDeclarations = last.Editor.Declarations.Count,
+ editorScopes = last.Editor.Scopes.Count,
+ editorInvocations = last.Editor.Invocations.Count,
+ selectedInvocations = last.Editor.Invocations.Count(fact => fact.Status == EditorInvocationStatus.Selected),
+ candidateOnlyInvocations = last.Editor.Invocations.Count(fact => fact.Status == EditorInvocationStatus.CandidatesOnly),
+ editorEstimatedBytes = last.Editor.EstimatedBytes,
+ receiverMembersAtQuerySites = lastGraph.QueryExpressions.Sum(probe => last.Editor.GetReceiverMembers(probe.Document, probe.Owner).Members.Count),
+ visibleBindingsAtQuerySites = lastGraph.QueryScopes.Sum(site => last.Editor.GetVisibleBindings(site.Document, site.Offset).Count),
+ queryReadBatch = mode == CheckMode.Editor ? MeasureQueries(lastGraph, last) : null,
+#endif
+ fingerprints,
+ });
+ }
+}
+var report = new
+{
+ implementation = args[1],
+ timestampUtc = DateTime.UtcNow,
+ runtime = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription,
+ os = System.Runtime.InteropServices.RuntimeInformation.OSDescription,
+ architecture = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString(),
+ processorCount = Environment.ProcessorCount,
+ tieredCompilation = Environment.GetEnvironmentVariable("DOTNET_TieredCompilation") ?? "runtime default",
+ samples = sampleCount,
+ methodology = "Fresh pure-VFS editor-enabled parsed module graph for each check; parse/resolution/setup excluded. Timed: checker construction, one CheckModules, frozen lexical/member/editor publication and diagnostics copy. Ordinary/member/editor variants add only their requested capture. 30 warmups, 31 full-GC-isolated batches. Fingerprints outside timing: diagnostics, public structural expression/class types (nominal class IDs normalized to per-graph ordinals, retaining identity partitions), lexical declaration/use identity partitions. Retention: full-GC live heap delta from an already parsed graph to a checked publication with checker released. A separate query-read batch measures immutable lookups only, without checking, resolution, or new capture. Baseline archived without instrumentation; no disk I/O in timed checks.",
+ reports,
+};
+File.WriteAllText(args[0], JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }));
+Console.WriteLine(args[0]);
+
+static Graph Prepare(bool multi)
+{
+ string folder = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "SharpTS-Editor-Semantics-Benchmark"));
+ int count = multi ? 25 : 1;
+ var sources = Enumerable.Range(0, count).ToDictionary(
+ index => Path.Combine(folder, $"module{index}.ts"),
+ index => multi ? MultiSource(index) : SmallSource(), StringComparer.OrdinalIgnoreCase);
+ string entryPath = Path.Combine(folder, $"module{count - 1}.ts");
+ var resolver = new ModuleResolver(entryPath, ModuleResolutionOptions.Default, sources,
+ new TypeScriptProgramOptions { NoLib = true, Types = [] }) { CaptureEditorSyntax = true };
+ var modules = resolver.GetModulesInOrder(resolver.LoadProgram(entryPath));
+ var expressions = modules.SelectMany(module => module.Document!.EditorSyntax!.Records)
+ .Select(record => record.Node).OfType().Distinct(ReferenceEqualityComparer.Instance).ToArray();
+ var tokens = modules.SelectMany(module => module.Tokens.Select(token => (module.Document!, token)))
+ .Where(pair => pair.token.Start >= 0 && pair.token.End > pair.token.Start).ToArray();
+ var queryExpressions = modules.SelectMany(module => module.Document!.EditorSyntax!.Records
+ .Where(record => record.Node is Expr.Get or Expr.GetPrivate or Expr.Call or Expr.New)
+ .DistinctBy(record => record.Node, ReferenceEqualityComparer.Instance).Take(4)
+ .Select(record => new QueryProbe(module.Document!, record.Node switch
+ {
+ Expr.Get access => access.Object,
+ Expr.GetPrivate access => access.Object,
+ _ => (Expr)record.Node,
+ }, record.Span.Start))).ToArray();
+ var queryInvocations = modules.SelectMany(module => module.Document!.EditorSyntax!.Invocations.Take(4)
+ .Select(invocation => new QueryProbe(module.Document!, invocation.Owner, invocation.OpenParen.End))).ToArray();
+ var queryScopes = modules.Select(module => (module.Document!,
+ module.Document!.Text.IndexOf("/*scope*/", StringComparison.Ordinal) + "/*scope*/".Length)).ToArray();
+ if (queryScopes.Any(site => site.Item2 < "/*scope*/".Length))
+ throw new InvalidOperationException("Every fixture module needs a scope query marker.");
+ return new(resolver, modules, expressions, tokens, queryExpressions, queryInvocations, queryScopes);
+}
+
+[MethodImpl(MethodImplOptions.NoInlining)]
+static Published Check(Graph graph, CheckMode mode)
+{
+ var checker = new TypeChecker();
+#if !BASELINE
+ if (mode == CheckMode.Members) checker.WithMemberProvenance();
+ if (mode == CheckMode.Editor) checker.WithEditorMetadata();
+#endif
+ TypeMap types = checker.CheckModules(graph.Modules, graph.Resolver);
+ var bindings = checker.Bindings.Freeze();
+ var diagnostics = checker.GetDiagnostics().ToArray();
+ if (diagnostics.Length != 0)
+ throw new InvalidOperationException("Benchmark fixtures must remain checker-valid: " +
+ string.Join(Environment.NewLine, diagnostics.Select(diagnostic => diagnostic.Message)));
+#if BASELINE
+ return new(types, bindings, diagnostics, checker.Members.Freeze());
+#else
+ return new(types, bindings, diagnostics, checker.Members.Freeze(), checker.EditorFacts.Freeze());
+#endif
+}
+
+[MethodImpl(MethodImplOptions.NoInlining)]
+static long Retained(bool multi, CheckMode mode)
+{
+ Graph graph = Prepare(multi);
+ GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
+ long before = GC.GetTotalMemory(forceFullCollection: true);
+ Published held = Check(graph, mode);
+ long after = GC.GetTotalMemory(forceFullCollection: true);
+ GC.KeepAlive(graph); GC.KeepAlive(held);
+ return after - before;
+}
+
+#if !BASELINE
+static QueryReadReport MeasureQueries(Graph graph, Published published)
+{
+ const int querySamples = 31;
+ const int repetitions = 10;
+ for (int warmup = 0; warmup < 30; warmup++) ReadQueryBatch(graph, published);
+ var times = new List();
+ var allocations = new List();
+ long checksum = 0;
+ for (int sample = 0; sample < querySamples; sample++)
+ {
+ GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ long started = Stopwatch.GetTimestamp();
+ for (int repetition = 0; repetition < repetitions; repetition++) checksum += ReadQueryBatch(graph, published);
+ times.Add(Stopwatch.GetElapsedTime(started).TotalMilliseconds / repetitions);
+ allocations.Add((GC.GetAllocatedBytesForCurrentThread() - before) / repetitions);
+ }
+ times.Sort(); allocations.Sort();
+ GC.KeepAlive(graph); GC.KeepAlive(published);
+ return new(graph.QueryExpressions.Count * 3 + graph.QueryInvocations.Count * 2 + graph.QueryScopes.Count,
+ querySamples * repetitions, times[querySamples / 2], times[(int)Math.Ceiling(querySamples * .9) - 1],
+ allocations[querySamples / 2], checksum);
+}
+
+[MethodImpl(MethodImplOptions.NoInlining)]
+static long ReadQueryBatch(Graph graph, Published published)
+{
+ long checksum = 0;
+ foreach (QueryProbe probe in graph.QueryExpressions)
+ {
+ checksum += published.Editor.GetOccurrence(probe.Document, probe.Owner)?.Type.Text.Length ?? 0;
+ checksum += published.Editor.FindOccurrence(probe.Document, probe.Offset)?.Type.Text.Length ?? 0;
+ checksum += published.Editor.GetReceiverMembers(probe.Document, probe.Owner).Members.Count;
+ }
+ foreach (QueryProbe probe in graph.QueryInvocations)
+ {
+ checksum += published.Editor.GetInvocation(probe.Document, probe.Owner)?.Candidates.Count ?? 0;
+ checksum += published.Editor.FindInvocation(probe.Document, probe.Offset)?.SelectedOrdinal ?? 0;
+ }
+ foreach ((SourceDocument document, int offset) in graph.QueryScopes)
+ checksum += published.Editor.GetVisibleBindings(document, offset).Count;
+ return checksum;
+}
+#endif
+
+static object Fingerprints(Graph graph, Published published)
+{
+ var diagnosticText = new StringBuilder();
+ foreach (Diagnostic diagnostic in published.Diagnostics.OrderBy(item => item.FilePath).ThenBy(item => item.Line)
+ .ThenBy(item => item.Column).ThenBy(item => item.Code).ThenBy(item => item.Message))
+ diagnosticText.Append(Path.GetFileName(diagnostic.FilePath)).Append(':').Append(diagnostic.Line)
+ .Append(':').Append(diagnostic.Column).Append(':').Append(diagnostic.Severity).Append(':')
+ .Append(diagnostic.Code).Append(':').Append(diagnostic.TsCode).Append(':').Append(diagnostic.Message).AppendLine();
+
+ var types = new List();
+ foreach (ParsedModule module in graph.Modules)
+ {
+ types.Add(module.ModuleName);
+ foreach (EditorSyntaxRecord syntax in module.Document!.EditorSyntax!.Records
+ .Where(record => record.Node is Expr).DistinctBy(record => record.Node, ReferenceEqualityComparer.Instance))
+ {
+ types.Add(syntax.Span.Start); types.Add(syntax.Span.End); types.Add(syntax.Node.GetType().Name);
+ types.Add(published.Types.Get((Expr)syntax.Node));
+ }
+ }
+ types.Add(published.Types.ClassTypes);
+
+ var lexical = new StringBuilder();
+ foreach ((SourceDocument document, Token token) in graph.Tokens)
+ {
+ var symbols = published.Bindings.FindSymbols(document, token.Start)
+ .OrderBy(symbol => symbol.Namespace).ThenBy(symbol => symbol.Name).ToArray();
+ if (symbols.Length == 0) continue;
+ lexical.Append(Path.GetFileName(document.Path)).Append(':').Append(token.Start).Append(':').Append(token.End);
+ foreach (FrozenBindingSymbol symbol in symbols)
+ {
+ lexical.Append('|').Append(symbol.Namespace).Append(':').Append(symbol.Name).Append(" declarations[");
+ foreach (BindingDeclaration declaration in symbol.Declarations.OrderBy(item => item.Document.Path).ThenBy(item => item.Name.Start))
+ lexical.Append(Path.GetFileName(declaration.Document.Path)).Append(':').Append(declaration.Name.Start).Append(',');
+ lexical.Append("] uses[");
+ foreach (BindingOccurrence occurrence in published.Bindings.FindReferences([symbol], true))
+ lexical.Append(Path.GetFileName(occurrence.Document.Path)).Append(':').Append(occurrence.Name.Start)
+ .Append(':').Append(occurrence.IsDeclaration).Append(',');
+ lexical.Append(']');
+ }
+ lexical.AppendLine();
+ }
+ return new { diagnostics = Hash(diagnosticText.ToString()), types = StructuralHash(types), lexical = Hash(lexical.ToString()) };
+}
+
+static string StructuralHash(object root)
+{
+ var text = new StringBuilder();
+ var visited = new Dictionary(ReferenceEqualityComparer.Instance);
+ var nominalClasses = new Dictionary();
+ Write(root);
+ return Hash(text.ToString());
+ void Write(object? value)
+ {
+ if (value is null) { text.Append("null;"); return; }
+ Type type = value.GetType();
+ if (value is string word) { text.Append(word.Length).Append(':').Append(word).Append(';'); return; }
+ if (type.IsPrimitive || type.IsEnum || value is decimal or System.Numerics.BigInteger)
+ { text.Append(type.Name).Append(':').Append(Convert.ToString(value, CultureInfo.InvariantCulture)).Append(';'); return; }
+ if (visited.TryGetValue(value, out int identity)) { text.Append('@').Append(identity).Append(';'); return; }
+ visited[value] = visited.Count;
+ // Concrete frozen dictionary implementation names/bucket order are runtime details.
+ bool dictionary = type.GetInterfaces().Any(item => item.IsGenericType &&
+ item.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>));
+ text.Append(dictionary ? "dictionary" : type.FullName).Append('{');
+ if (value is IEnumerable sequence)
+ {
+ object?[] items = sequence.Cast().ToArray();
+ if (dictionary) items = items.OrderBy(item => item!.GetType().GetProperty("Key")!.GetValue(item)?.ToString(), StringComparer.Ordinal).ToArray();
+ else if (type.GetInterfaces().Any(item => item.IsGenericType && item.GetGenericTypeDefinition() == typeof(ISet<>)))
+ items = items.OrderBy(item => item?.ToString(), StringComparer.Ordinal).ToArray();
+ foreach (object? item in items) Write(item);
+ }
+ else
+ foreach (PropertyInfo property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
+ .Where(property => property.GetIndexParameters().Length == 0)
+ .OrderBy(property => property.Name, StringComparer.Ordinal))
+ {
+ text.Append(property.Name).Append('=');
+ if ((property.Name == "DeclarationId" && value is ClassMetadataCore or TypeInfo.MutableClass) ||
+ (property.Name == "DeclaringClassId" && value is MemberAccessBrand))
+ {
+ int nominalId = (int)property.GetValue(value)!;
+ // Global numeric IDs differ between fresh checks/processes. Keep zero as
+ // locationless and preserve every nonzero identity's equivalence partition.
+ if (nominalId == 0) text.Append("class:0;");
+ else
+ {
+ if (!nominalClasses.TryGetValue(nominalId, out int ordinal))
+ nominalClasses.Add(nominalId, ordinal = nominalClasses.Count + 1);
+ text.Append("class:").Append(ordinal).Append(';');
+ }
+ }
+ else Write(property.GetValue(value));
+ }
+ text.Append('}');
+ }
+}
+
+static string Hash(string text) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text)));
+
+static void VerifyNominalFingerprint()
+{
+ object[] distinct = [new TypeInfo.MutableClass("Same", 101), new TypeInfo.MutableClass("Same", 102),
+ new MemberAccessBrand(AccessModifier.Private, 101), new MemberAccessBrand(AccessModifier.Protected, 102)];
+ object[] remapped = [new TypeInfo.MutableClass("Same", 901), new TypeInfo.MutableClass("Same", 902),
+ new MemberAccessBrand(AccessModifier.Private, 901), new MemberAccessBrand(AccessModifier.Protected, 902)];
+ object[] merged = [new TypeInfo.MutableClass("Same", 901), new TypeInfo.MutableClass("Same", 901),
+ new MemberAccessBrand(AccessModifier.Private, 901), new MemberAccessBrand(AccessModifier.Protected, 901)];
+ object[] locationless = [new TypeInfo.MutableClass("Same", 0), new TypeInfo.MutableClass("Same", 902),
+ new MemberAccessBrand(AccessModifier.Private, 0), new MemberAccessBrand(AccessModifier.Protected, 902)];
+ object[] wrongBrands = [new TypeInfo.MutableClass("Same", 901), new TypeInfo.MutableClass("Same", 902),
+ new MemberAccessBrand(AccessModifier.Private, 902), new MemberAccessBrand(AccessModifier.Protected, 901)];
+ string expected = StructuralHash(distinct);
+ if (expected != StructuralHash(remapped) || expected == StructuralHash(merged) ||
+ expected == StructuralHash(locationless) || expected == StructuralHash(wrongBrands))
+ throw new InvalidOperationException("Nominal type fingerprint must preserve identity partitions and locationless zero.");
+}
+
+static string SmallSource() => """
+ export class Counter {
+ value: number = 1;
+ #secret: number = 2;
+ static total: number = 0;
+ get doubled(): number { return this.value * 2; }
+ set doubled(next: number) { this.value = next / 2; }
+ increment(delta: number): number { this.value = this.value + delta; return this.#secret + this.value; }
+ has(other: Counter): boolean { return #secret in other; }
+ #read(): number { return this.#secret; }
+ readSecret(): number { return this.#read(); }
+ }
+ function identity(value: T): T { return value; }
+ function select(value: number): number;
+ function select(value: string): number;
+ function select(value: any): number { return 1; }
+ const counter = new Counter();
+ counter.value = counter.increment(3);
+ counter.doubled = counter["value"];
+ const result: number = counter.doubled + Counter.total;
+ function scoped(input: number): number {
+ let value: number = identity(input);
+ { const value: string = 'inner'; /*scope*/select(value); }
+ return select(value) + counter.readSecret();
+ }
+ const checked: number = scoped(result);
+ """;
+
+static string MultiSource(int index) => $$"""
+ {{(index == 0 ? "" : $"import {{ Box{index - 1} as Previous }} from './module{index - 1}';")}}
+ export class Base{{index}} {
+ value: number = {{index}};
+ #secret: number = {{index + 1}};
+ static total: number = 0;
+ get doubled(): number { return this.value * 2; }
+ set doubled(next: number) { this.value = next / 2; }
+ increment(delta: number): number { this.value = this.value + delta; return this.#secret + this.value; }
+ has(other: Base{{index}}): boolean { return #secret in other; }
+ #read(): number { return this.#secret; }
+ readSecret(): number { return this.#read(); }
+ }
+ export class Derived{{index}} extends Base{{index}} {
+ own: number = 2;
+ sum(): number { return this.increment(this.own) + this.value; }
+ }
+ export class Box{{index}} {
+ value: T;
+ constructor(value: T) { this.value = value; }
+ read(): T { return this.value; }
+ update(next: T): void { this.value = next; }
+ }
+ export function identity{{index}}(value: T): T { return value; }
+ export function select{{index}}(value: number): number;
+ export function select{{index}}(value: string): number;
+ export function select{{index}}(value: any): number { return 1; }
+ export function run{{index}}(): number {
+ const derived = new Derived{{index}}();
+ derived.value = derived.increment(3);
+ derived.doubled = derived["value"];
+ const box = new Box{{index}}(derived.sum());
+ box.update(box.read() + Base{{index}}.total);
+ {{(index == 0 ? "" : "const previous = new Previous(1); box.update(previous.read());")}}
+ let selected: number = identity{{index}}(select{{index}}(box.value));
+ { const selected: string = 'inner'; /*scope*/select{{index}}(selected); }
+ return selected + derived.doubled + derived.readSecret();
+ }
+ """;
+
+sealed record Graph(ModuleResolver Resolver, List Modules, IReadOnlyList Expressions,
+ IReadOnlyList<(SourceDocument Document, Token Token)> Tokens, IReadOnlyList QueryExpressions,
+ IReadOnlyList QueryInvocations, IReadOnlyList<(SourceDocument Document, int Offset)> QueryScopes);
+sealed record QueryProbe(SourceDocument Document, Expr Owner, int Offset);
+sealed record Published(TypeMap Types, FrozenBindingIndex Bindings, IReadOnlyList Diagnostics,
+ FrozenMemberIndex Members
+#if !BASELINE
+ , FrozenEditorSemanticIndex Editor
+#endif
+);
+enum CheckMode { Ordinary, Members, Editor }
+#if !BASELINE
+sealed record QueryReadReport(int QueriesPerBatch, int TimedBatches, double MedianMilliseconds,
+ double P90Milliseconds, long MedianAllocatedBytes, long Checksum);
+#endif
diff --git a/benchmarks/editor-semantics/README.md b/benchmarks/editor-semantics/README.md
new file mode 100644
index 000000000..64be53198
--- /dev/null
+++ b/benchmarks/editor-semantics/README.md
@@ -0,0 +1,39 @@
+This optional runner measures the checker and publication cost of editor semantic metadata. It is outside the solution and does not affect ordinary builds. The earlier member-provenance benchmark is unchanged.
+
+Run from the repository root with .NET 10 and PowerShell:
+
+```powershell
+./benchmarks/editor-semantics/run.ps1
+```
+
+The script archives the exact `1cfcc46a` baseline into ignored `artifacts/editor-semantics-benchmark` without instrumentation. It builds that compiler and the current compiler, then compiles the same runner against each DLL directory. `-PackageCache ` uses an existing offline NuGet cache. `-OutputDirectory ` changes the scratch directory. `-SkipLibraryBuilds` reuses both previously built compiler directories; the small runners are still rebuilt against those DLLs.
+
+Four variants run on identical source graphs: baseline ordinary checking, current ordinary checking, current member provenance capture, and current editor metadata capture. Editor capture also enables member provenance, so its difference from member-only capture measures the added cold check and publication cost of types, declarations, scopes, receiver sets and invocation candidates/selections. Preparation creates a fresh graph parsed with editor syntax for every check, using pure VFS resolution with default declaration libraries disabled. Parsing, resolution, graph preparation and fingerprinting are outside the timed interval. Timed work constructs one checker, calls `CheckModules` once, copies diagnostics and freezes the requested indexes. No metadata-only semantic recheck is performed.
+
+The small fixture covers source instance/static/private members, accessors, a private call, literal indexing, writes, private presence, generic calls, public overloads and a nested shadowing scope. The 25-module fixture adds imported generic receivers, inherited members and constructors, generic classes, and the same scope and overload work in each module. Each fixture must have zero diagnostics; the runner fails otherwise. All variants use the exact same prepared source texts. Parameter properties remain excluded because their separate rename policy is covered by focused tests.
+
+Each variant has 30 warmups and 31 batched samples with tiered compilation disabled. Every batch starts after a full GC with its fresh graphs alive. Reports include median/p90 check latency, per-thread allocated bytes, frozen record counts, the indexes' approximate retained-byte estimates, and a noisy full-GC live heap delta from an already parsed graph to a retained publication with the mutable checker released. Allocations include transient work and must not be interpreted as retained heap growth. Index estimates omit some runtime object and collection overhead and are not process heap limits.
+
+Editor mode also measures a separate immutable query-read batch against its final checked publication: exact-owner and cursor type lookups, receiver sets, invocation candidates/selections and visible bindings at a nested block. Query plans are prepared outside timing. The read measurement contains no lexing, parsing, resolution, inference or checker calls. It measures these core lookup APIs, without LSP transport, handler rendering, cancellation or snapshot acquisition.
+
+The script rejects changes in diagnostic contents/positions, public structural expression/class types, and lexical declaration/use identity partitions across all four variants. Numeric nominal class IDs are normalized to deterministic per-graph ordinals, including access brands. Zero stays distinct, and the fingerprint preserves nonzero identity equivalence partitions rather than merging by class name or source shape. The inherited fingerprint self-check verifies remapping invariance and rejects merged, locationless and wrongly branded classes. These checks occur outside timing.
+
+Raw reports and a combined `comparison.json` are written to the scratch directory. A single-machine measurement can identify a regression worth investigating, but it does not establish a general speedup or end-to-end editor latency. Final epic validation in #1981 still needs interactive measurements with snapshot reuse.
+
+The recorded Windows ARM64/.NET 10.0.12 run is in [results-2026-10-07.json](results-2026-10-07.json), using SDK 10.0.401. All three fingerprints match across all four variants, with zero diagnostics. Ordinary median checking changed by -9.26%/-0.06% for the small/25-module fixtures; allocated bytes increased by 1.31%/1.15%. Neither ordinary median exceeds the 10% investigation threshold. The small baseline has a wider p90 than the current run, so the negative changes should not be treated as evidence of a speedup.
+
+| Fixture | Baseline ordinary | Current ordinary | Current members | Current editor | Editor over members |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| Small | 0.3091 ms | 0.2805 ms | 0.4714 ms | 1.1378 ms | +0.6664 ms |
+| 25 modules | 88.3204 ms | 88.2708 ms | 104.7844 ms | 149.2828 ms | +44.4984 ms |
+
+Editor capture takes 2.414×/1.425× the member-only cold check time. It adds 504,298/20,393,888 allocated bytes and 69,912/2,830,328 retained heap bytes over member-only capture. Those transient allocations include collecting and freezing scopes, receiver sets, invocation attempts and type/signature presentations. This is an attribution from code inspection rather than measured phase profiling. The retained increase is much smaller than allocation churn.
+
+The small/25-module publications contain 226/9,253 editor facts, including 24/826 scopes and 8/372 selected invocations. The receiver query sites read 28/700 member entries, and the nested scope sites read 13/447 visible bindings. The editor index estimates 40,616/1,638,748 bytes, below the extra measured retained heap over member-only capture. The member index is separate and estimates another 6,318/282,654 bytes. These approximate estimates do not include every runtime collection, object, string and identity-map overhead; cache budgets remain estimates.
+
+| Immutable read batch | Queries | Median / p90 | Allocated bytes per batch |
+| --- | ---: | ---: | ---: |
+| Small | 21 | 0.00975 / 0.00995 ms | 8,064 |
+| 25 modules | 525 | 2.45651 / 2.49565 ms | 234,624 |
+
+These batches combine exact-owner dictionary lookups with cursor occurrence and visible-scope scans and returned lists. Their scaling should not be generalized to every individual query API. Snapshot reuse avoids repeating the cold check; these measurements do not include LSP handlers, transport, rendering or end-to-end interactive behavior.
diff --git a/benchmarks/editor-semantics/results-2026-10-07.json b/benchmarks/editor-semantics/results-2026-10-07.json
new file mode 100644
index 000000000..4ba5db3b9
--- /dev/null
+++ b/benchmarks/editor-semantics/results-2026-10-07.json
@@ -0,0 +1,320 @@
+{
+ "baselineCommit": "1cfcc46a",
+ "current": "Uncommitted #1976 implementation on 1cfcc46a.",
+ "baselineTimestampUtc": "2026-10-07T07:35:55.6173732Z",
+ "currentTimestampUtc": "2026-10-07T07:37:02.3852475Z",
+ "environment": {
+ "runtime": ".NET 10.0.12",
+ "os": "Microsoft Windows 10.0.26220",
+ "architecture": "Arm64",
+ "processorCount": 12,
+ "tieredCompilation": "0",
+ "sdk": "10.0.401"
+ },
+ "samples": 31,
+ "warmupsPerVariant": 30,
+ "methodology": "Fresh pure-VFS editor-enabled parsed module graph for each check; parse/resolution/setup excluded. Timed: checker construction, one CheckModules, frozen lexical/member/editor publication and diagnostics copy. Ordinary/member/editor variants add only their requested capture. 30 warmups, 31 full-GC-isolated batches. Fingerprints outside timing: diagnostics, public structural expression/class types (nominal class IDs normalized to per-graph ordinals, retaining identity partitions), lexical declaration/use identity partitions. Retention: full-GC live heap delta from an already parsed graph to a checked publication with checker released. A separate query-read batch measures immutable lookups only, without checking, resolution, or new capture. Baseline archived without instrumentation; no disk I/O in timed checks.",
+ "replay": "./benchmarks/editor-semantics/run.ps1 -PackageCache ",
+ "fixtures": [
+ {
+ "name": "small",
+ "allVariantFingerprintsMatch": true,
+ "fingerprints": {
+ "diagnostics": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
+ "types": "BABE25786B8A476F2113FC7A3804830C844133A1884B35113F595803E5239E8B",
+ "lexical": "AA004312A1D620C2222699E980D914B6DFE1268AA072AF3E90F72AFC000BD395"
+ },
+ "variants": {
+ "baselineOrdinary": {
+ "fixture": "small",
+ "mode": "Ordinary",
+ "sourceFiles": 1,
+ "sourceUtf16Bytes": 2128,
+ "repetitionsPerSample": 12,
+ "timedChecks": 372,
+ "medianMilliseconds": 0.3091,
+ "p90Milliseconds": 0.5530583333333333,
+ "medianAllocatedBytes": 240612,
+ "retainedPublishedHeapDeltaBytes": 31128,
+ "diagnosticCount": 0,
+ "typedExpressions": 66,
+ "lexicalTokenCount": 278,
+ "memberSymbols": 0,
+ "memberOccurrences": 0,
+ "memberEstimatedBytes": 0,
+ "fingerprints": {
+ "diagnostics": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
+ "types": "BABE25786B8A476F2113FC7A3804830C844133A1884B35113F595803E5239E8B",
+ "lexical": "AA004312A1D620C2222699E980D914B6DFE1268AA072AF3E90F72AFC000BD395"
+ }
+ },
+ "currentOrdinary": {
+ "fixture": "small",
+ "mode": "Ordinary",
+ "sourceFiles": 1,
+ "sourceUtf16Bytes": 2128,
+ "repetitionsPerSample": 12,
+ "timedChecks": 372,
+ "medianMilliseconds": 0.28049166666666664,
+ "p90Milliseconds": 0.28858333333333336,
+ "medianAllocatedBytes": 243764,
+ "retainedPublishedHeapDeltaBytes": 31136,
+ "diagnosticCount": 0,
+ "typedExpressions": 66,
+ "lexicalTokenCount": 278,
+ "memberSymbols": 0,
+ "memberOccurrences": 0,
+ "memberEstimatedBytes": 0,
+ "editorFacts": 0,
+ "editorOccurrences": 0,
+ "editorDeclarations": 0,
+ "editorScopes": 0,
+ "editorInvocations": 0,
+ "selectedInvocations": 0,
+ "candidateOnlyInvocations": 0,
+ "editorEstimatedBytes": 0,
+ "receiverMembersAtQuerySites": 0,
+ "visibleBindingsAtQuerySites": 0,
+ "queryReadBatch": null,
+ "fingerprints": {
+ "diagnostics": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
+ "types": "BABE25786B8A476F2113FC7A3804830C844133A1884B35113F595803E5239E8B",
+ "lexical": "AA004312A1D620C2222699E980D914B6DFE1268AA072AF3E90F72AFC000BD395"
+ }
+ },
+ "currentMembers": {
+ "fixture": "small",
+ "mode": "Members",
+ "sourceFiles": 1,
+ "sourceUtf16Bytes": 2128,
+ "repetitionsPerSample": 12,
+ "timedChecks": 372,
+ "medianMilliseconds": 0.4713583333333333,
+ "p90Milliseconds": 0.4969166666666667,
+ "medianAllocatedBytes": 425614,
+ "retainedPublishedHeapDeltaBytes": 45848,
+ "diagnosticCount": 0,
+ "typedExpressions": 66,
+ "lexicalTokenCount": 278,
+ "memberSymbols": 8,
+ "memberOccurrences": 24,
+ "memberEstimatedBytes": 6318,
+ "editorFacts": 0,
+ "editorOccurrences": 0,
+ "editorDeclarations": 0,
+ "editorScopes": 0,
+ "editorInvocations": 0,
+ "selectedInvocations": 0,
+ "candidateOnlyInvocations": 0,
+ "editorEstimatedBytes": 0,
+ "receiverMembersAtQuerySites": 0,
+ "visibleBindingsAtQuerySites": 0,
+ "queryReadBatch": null,
+ "fingerprints": {
+ "diagnostics": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
+ "types": "BABE25786B8A476F2113FC7A3804830C844133A1884B35113F595803E5239E8B",
+ "lexical": "AA004312A1D620C2222699E980D914B6DFE1268AA072AF3E90F72AFC000BD395"
+ }
+ },
+ "currentEditor": {
+ "fixture": "small",
+ "mode": "Editor",
+ "sourceFiles": 1,
+ "sourceUtf16Bytes": 2128,
+ "repetitionsPerSample": 12,
+ "timedChecks": 372,
+ "medianMilliseconds": 1.137775,
+ "p90Milliseconds": 1.2735416666666668,
+ "medianAllocatedBytes": 929912,
+ "retainedPublishedHeapDeltaBytes": 115760,
+ "diagnosticCount": 0,
+ "typedExpressions": 66,
+ "lexicalTokenCount": 278,
+ "memberSymbols": 8,
+ "memberOccurrences": 24,
+ "memberEstimatedBytes": 6318,
+ "editorFacts": 226,
+ "editorOccurrences": 66,
+ "editorDeclarations": 30,
+ "editorScopes": 24,
+ "editorInvocations": 8,
+ "selectedInvocations": 8,
+ "candidateOnlyInvocations": 0,
+ "editorEstimatedBytes": 40616,
+ "receiverMembersAtQuerySites": 28,
+ "visibleBindingsAtQuerySites": 13,
+ "queryReadBatch": {
+ "QueriesPerBatch": 21,
+ "TimedBatches": 310,
+ "MedianMilliseconds": 0.00975,
+ "P90Milliseconds": 0.00995,
+ "MedianAllocatedBytes": 8064,
+ "Checksum": 31310
+ },
+ "fingerprints": {
+ "diagnostics": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
+ "types": "BABE25786B8A476F2113FC7A3804830C844133A1884B35113F595803E5239E8B",
+ "lexical": "AA004312A1D620C2222699E980D914B6DFE1268AA072AF3E90F72AFC000BD395"
+ }
+ }
+ },
+ "ordinaryMedianChangePercent": -9.255365038283191,
+ "ordinaryAllocatedChangePercent": 1.3099928515618586,
+ "editorOverMembersMedianMilliseconds": 0.6664166666666667,
+ "editorOverMembersAllocatedBytes": 504298,
+ "editorOverMembersRetainedHeapDeltaBytes": 69912,
+ "editorOverMembersMedianRatio": 2.413821756271768
+ },
+ {
+ "name": "25-module-scopes-overloads-generics",
+ "allVariantFingerprintsMatch": true,
+ "fingerprints": {
+ "diagnostics": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
+ "types": "95CEA31D9F0A89623899ADCAEED13399D8C4AF5A8BD6DA0919D71210B81D3005",
+ "lexical": "2E8ABBE1A6239E6B5A71F5D001DC8A20C79DCC8FAAE7436214B902993711794E"
+ },
+ "variants": {
+ "baselineOrdinary": {
+ "fixture": "25-module-scopes-overloads-generics",
+ "mode": "Ordinary",
+ "sourceFiles": 25,
+ "sourceUtf16Bytes": 78068,
+ "repetitionsPerSample": 2,
+ "timedChecks": 62,
+ "medianMilliseconds": 88.32035,
+ "p90Milliseconds": 92.82965,
+ "medianAllocatedBytes": 9034720,
+ "retainedPublishedHeapDeltaBytes": 1052224,
+ "diagnosticCount": 0,
+ "typedExpressions": 2566,
+ "lexicalTokenCount": 10318,
+ "memberSymbols": 0,
+ "memberOccurrences": 0,
+ "memberEstimatedBytes": 0,
+ "fingerprints": {
+ "diagnostics": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
+ "types": "95CEA31D9F0A89623899ADCAEED13399D8C4AF5A8BD6DA0919D71210B81D3005",
+ "lexical": "2E8ABBE1A6239E6B5A71F5D001DC8A20C79DCC8FAAE7436214B902993711794E"
+ }
+ },
+ "currentOrdinary": {
+ "fixture": "25-module-scopes-overloads-generics",
+ "mode": "Ordinary",
+ "sourceFiles": 25,
+ "sourceUtf16Bytes": 78068,
+ "repetitionsPerSample": 2,
+ "timedChecks": 62,
+ "medianMilliseconds": 88.2708,
+ "p90Milliseconds": 93.9062,
+ "medianAllocatedBytes": 9138728,
+ "retainedPublishedHeapDeltaBytes": 1060056,
+ "diagnosticCount": 0,
+ "typedExpressions": 2566,
+ "lexicalTokenCount": 10318,
+ "memberSymbols": 0,
+ "memberOccurrences": 0,
+ "memberEstimatedBytes": 0,
+ "editorFacts": 0,
+ "editorOccurrences": 0,
+ "editorDeclarations": 0,
+ "editorScopes": 0,
+ "editorInvocations": 0,
+ "selectedInvocations": 0,
+ "candidateOnlyInvocations": 0,
+ "editorEstimatedBytes": 0,
+ "receiverMembersAtQuerySites": 0,
+ "visibleBindingsAtQuerySites": 0,
+ "queryReadBatch": null,
+ "fingerprints": {
+ "diagnostics": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
+ "types": "95CEA31D9F0A89623899ADCAEED13399D8C4AF5A8BD6DA0919D71210B81D3005",
+ "lexical": "2E8ABBE1A6239E6B5A71F5D001DC8A20C79DCC8FAAE7436214B902993711794E"
+ }
+ },
+ "currentMembers": {
+ "fixture": "25-module-scopes-overloads-generics",
+ "mode": "Members",
+ "sourceFiles": 25,
+ "sourceUtf16Bytes": 78068,
+ "repetitionsPerSample": 2,
+ "timedChecks": 62,
+ "medianMilliseconds": 104.78435,
+ "p90Milliseconds": 107.1269,
+ "medianAllocatedBytes": 17207280,
+ "retainedPublishedHeapDeltaBytes": 1671616,
+ "diagnosticCount": 0,
+ "typedExpressions": 2566,
+ "lexicalTokenCount": 10318,
+ "memberSymbols": 350,
+ "memberOccurrences": 1048,
+ "memberEstimatedBytes": 282654,
+ "editorFacts": 0,
+ "editorOccurrences": 0,
+ "editorDeclarations": 0,
+ "editorScopes": 0,
+ "editorInvocations": 0,
+ "selectedInvocations": 0,
+ "candidateOnlyInvocations": 0,
+ "editorEstimatedBytes": 0,
+ "receiverMembersAtQuerySites": 0,
+ "visibleBindingsAtQuerySites": 0,
+ "queryReadBatch": null,
+ "fingerprints": {
+ "diagnostics": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
+ "types": "95CEA31D9F0A89623899ADCAEED13399D8C4AF5A8BD6DA0919D71210B81D3005",
+ "lexical": "2E8ABBE1A6239E6B5A71F5D001DC8A20C79DCC8FAAE7436214B902993711794E"
+ }
+ },
+ "currentEditor": {
+ "fixture": "25-module-scopes-overloads-generics",
+ "mode": "Editor",
+ "sourceFiles": 25,
+ "sourceUtf16Bytes": 78068,
+ "repetitionsPerSample": 2,
+ "timedChecks": 62,
+ "medianMilliseconds": 149.28275,
+ "p90Milliseconds": 209.8663,
+ "medianAllocatedBytes": 37601168,
+ "retainedPublishedHeapDeltaBytes": 4501944,
+ "diagnosticCount": 0,
+ "typedExpressions": 2566,
+ "lexicalTokenCount": 10318,
+ "memberSymbols": 350,
+ "memberOccurrences": 1048,
+ "memberEstimatedBytes": 282654,
+ "editorFacts": 9253,
+ "editorOccurrences": 2566,
+ "editorDeclarations": 1097,
+ "editorScopes": 826,
+ "editorInvocations": 372,
+ "selectedInvocations": 372,
+ "candidateOnlyInvocations": 0,
+ "editorEstimatedBytes": 1638748,
+ "receiverMembersAtQuerySites": 700,
+ "visibleBindingsAtQuerySites": 447,
+ "queryReadBatch": {
+ "QueriesPerBatch": 525,
+ "TimedBatches": 310,
+ "MedianMilliseconds": 2.45651,
+ "P90Milliseconds": 2.49565,
+ "MedianAllocatedBytes": 234624,
+ "Checksum": 733770
+ },
+ "fingerprints": {
+ "diagnostics": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
+ "types": "95CEA31D9F0A89623899ADCAEED13399D8C4AF5A8BD6DA0919D71210B81D3005",
+ "lexical": "2E8ABBE1A6239E6B5A71F5D001DC8A20C79DCC8FAAE7436214B902993711794E"
+ }
+ }
+ },
+ "ordinaryMedianChangePercent": -0.05610258564420212,
+ "ordinaryAllocatedChangePercent": 1.1512033577133485,
+ "editorOverMembersMedianMilliseconds": 44.49839999999999,
+ "editorOverMembersAllocatedBytes": 20393888,
+ "editorOverMembersRetainedHeapDeltaBytes": 2830328,
+ "editorOverMembersMedianRatio": 1.4246664697543097
+ }
+ ],
+ "baselineCommitResolved": "1cfcc46ac693f1c1d0147a0583e5b0af2b684012",
+ "currentBaseCommit": "1cfcc46ac693f1c1d0147a0583e5b0af2b684012"
+}
diff --git a/benchmarks/editor-semantics/run.ps1 b/benchmarks/editor-semantics/run.ps1
new file mode 100644
index 000000000..c5cdff998
--- /dev/null
+++ b/benchmarks/editor-semantics/run.ps1
@@ -0,0 +1,93 @@
+param(
+ [string]$BaselineCommit = '1cfcc46a',
+ [string]$PackageCache = '',
+ [string]$OutputDirectory = '',
+ [switch]$SkipLibraryBuilds
+)
+$ErrorActionPreference = 'Stop'
+$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
+if (!$OutputDirectory) { $OutputDirectory = Join-Path $repoRoot 'artifacts/editor-semantics-benchmark' }
+$outputRoot = [IO.Path]::GetFullPath($OutputDirectory)
+New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null
+$baselineRoot = Join-Path $outputRoot ('baseline-' + $BaselineCommit)
+$baselineArchive = Join-Path $outputRoot ('baseline-' + $BaselineCommit + '.zip')
+if (!(Test-Path -LiteralPath $baselineRoot)) {
+ & git -C $repoRoot archive --format=zip "--output=$baselineArchive" $BaselineCommit
+ if ($LASTEXITCODE) { throw 'git archive failed.' }
+ Expand-Archive -LiteralPath $baselineArchive -DestinationPath $baselineRoot
+}
+function Invoke-CheckedDotNet([string[]]$DotNetArguments) {
+ & dotnet @DotNetArguments
+ if ($LASTEXITCODE) { throw "dotnet failed: $($DotNetArguments[0])" }
+}
+$restoreOptions = @('-p:NuGetAudit=false', '-p:MinVerVersionOverride=0.0.0-benchmark')
+if ($PackageCache) {
+ $emptyFeed = Join-Path $outputRoot 'empty-feed'
+ New-Item -ItemType Directory -Path $emptyFeed -Force | Out-Null
+ $restoreOptions += @("-p:RestorePackagesPath=$([IO.Path]::GetFullPath($PackageCache))", '--source', $emptyFeed)
+}
+if (!$SkipLibraryBuilds) {
+ Invoke-CheckedDotNet (@('build', (Join-Path $baselineRoot 'src/SharpTS/SharpTS.csproj'), '-c', 'Release', '-v', 'minimal') + $restoreOptions)
+ Invoke-CheckedDotNet (@('build', (Join-Path $repoRoot 'src/SharpTS/SharpTS.csproj'), '-c', 'Release', '-v', 'minimal') + $restoreOptions)
+}
+foreach ($implementation in @('baseline', 'current')) {
+ $libraryRoot = if ($implementation -eq 'baseline') { $baselineRoot } else { $repoRoot }
+ $runnerOptions = @('build', (Join-Path $PSScriptRoot 'EditorSemantics.csproj'), '-c', 'Release', '-v', 'minimal',
+ "-p:LibraryDirectory=$libraryRoot/src/SharpTS/bin/Release/net10.0",
+ "-p:BaseOutputPath=$outputRoot/bin/$implementation/", "-p:BaseIntermediateOutputPath=$outputRoot/obj/$implementation/")
+ if ($implementation -eq 'baseline') { $runnerOptions += '-p:Baseline=true' }
+ Invoke-CheckedDotNet ($runnerOptions + $restoreOptions)
+ $label = if ($implementation -eq 'baseline') { $BaselineCommit } else { 'editor-semantics-current' }
+ $previousBenchmarkTiering = $env:DOTNET_TieredCompilation
+ try {
+ $env:DOTNET_TieredCompilation = '0'
+ Invoke-CheckedDotNet @((Join-Path $outputRoot "bin/$implementation/Release/net10.0/SharpTS.EditorSemanticsBenchmark.dll"),
+ (Join-Path $outputRoot "$implementation.json"), $label)
+ } finally { $env:DOTNET_TieredCompilation = $previousBenchmarkTiering }
+}
+$before = Get-Content -LiteralPath (Join-Path $outputRoot 'baseline.json') -Raw | ConvertFrom-Json
+$after = Get-Content -LiteralPath (Join-Path $outputRoot 'current.json') -Raw | ConvertFrom-Json
+foreach ($report in $after.reports) {
+ $original = @($before.reports | Where-Object { $_.fixture -eq $report.fixture -and $_.mode -eq 'Ordinary' })
+ if ($original.Count -ne 1) { throw "Expected one ordinary baseline for $($report.fixture)." }
+ foreach ($category in @('diagnostics', 'types', 'lexical')) {
+ if ($original[0].fingerprints.$category -ne $report.fingerprints.$category) {
+ throw "$category mismatch: $($report.fixture), mode=$($report.mode)"
+ }
+ }
+}
+$fixtures = foreach ($original in $before.reports) {
+ $current = @($after.reports | Where-Object fixture -eq $original.fixture)
+ $ordinary = $current | Where-Object mode -eq 'Ordinary'
+ $members = $current | Where-Object mode -eq 'Members'
+ $editor = $current | Where-Object mode -eq 'Editor'
+ [ordered]@{
+ name = $original.fixture
+ allVariantFingerprintsMatch = $true
+ fingerprints = $original.fingerprints
+ variants = [ordered]@{ baselineOrdinary = $original; currentOrdinary = $ordinary; currentMembers = $members; currentEditor = $editor }
+ ordinaryMedianChangePercent = ($ordinary.medianMilliseconds / $original.medianMilliseconds - 1) * 100
+ ordinaryAllocatedChangePercent = ($ordinary.medianAllocatedBytes / $original.medianAllocatedBytes - 1) * 100
+ editorOverMembersMedianMilliseconds = $editor.medianMilliseconds - $members.medianMilliseconds
+ editorOverMembersAllocatedBytes = $editor.medianAllocatedBytes - $members.medianAllocatedBytes
+ editorOverMembersRetainedHeapDeltaBytes = $editor.retainedPublishedHeapDeltaBytes - $members.retainedPublishedHeapDeltaBytes
+ editorOverMembersMedianRatio = $editor.medianMilliseconds / $members.medianMilliseconds
+ }
+}
+$comparison = [ordered]@{
+ baselineCommit = $BaselineCommit
+ baselineCommitResolved = (& git -C $repoRoot rev-parse "$BaselineCommit^{commit}").Trim()
+ currentBaseCommit = (& git -C $repoRoot rev-parse HEAD).Trim()
+ current = 'Uncommitted #1976 implementation on 1cfcc46a.'
+ baselineTimestampUtc = $before.timestampUtc
+ currentTimestampUtc = $after.timestampUtc
+ environment = [ordered]@{ sdk = (& dotnet --version).Trim(); runtime = $after.runtime; os = $after.os; architecture = $after.architecture; processorCount = $after.processorCount; tieredCompilation = $after.tieredCompilation }
+ samples = $after.samples
+ warmupsPerVariant = 30
+ methodology = $after.methodology
+ replay = './benchmarks/editor-semantics/run.ps1 -PackageCache '
+ fixtures = @($fixtures)
+}
+$comparisonPath = Join-Path $outputRoot 'comparison.json'
+$comparison | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $comparisonPath -Encoding utf8
+Write-Output "Matching diagnostics/types/lexical identities. Reports: $outputRoot/baseline.json, current.json and comparison.json"
diff --git a/benchmarks/editor-syntax/EditorSyntax.csproj b/benchmarks/editor-syntax/EditorSyntax.csproj
new file mode 100644
index 000000000..8cdcdef21
--- /dev/null
+++ b/benchmarks/editor-syntax/EditorSyntax.csproj
@@ -0,0 +1,14 @@
+
+
+ net10.0
+ Exe
+ SharpTS.SyntaxBenchmark
+ enable
+ enable
+ false
+ $(DefineConstants);BASELINE
+
+
+
+
+
diff --git a/benchmarks/editor-syntax/Program.cs b/benchmarks/editor-syntax/Program.cs
new file mode 100644
index 000000000..b33362e88
--- /dev/null
+++ b/benchmarks/editor-syntax/Program.cs
@@ -0,0 +1,154 @@
+using System.Collections;
+using System.Diagnostics;
+using System.Globalization;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using SharpTS.Parsing;
+
+const int samples = 31;
+var reports = new List();
+foreach (bool multi in new[] { false, true })
+{
+ string[] sources = Enumerable.Range(0, multi ? 25 : 1).Select(CreateSource).ToArray();
+ foreach (bool editor in new[] { false, true })
+ {
+#if BASELINE
+ if (editor) continue;
+#endif
+ for (int index = 0; index < 30; index++) Parse(sources, editor);
+ int repetitions = multi ? 3 : 40;
+ var times = new List();
+ var allocations = new List();
+ List last = [];
+ for (int sample = 0; sample < samples; sample++)
+ {
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ var watch = Stopwatch.StartNew();
+ for (int iteration = 0; iteration < repetitions; iteration++) last = Parse(sources, editor);
+ watch.Stop();
+ times.Add(watch.Elapsed.TotalMilliseconds / repetitions);
+ allocations.Add((GC.GetAllocatedBytesForCurrentThread() - before) / repetitions);
+ }
+ var ast = last.SelectMany(parsed => parsed.Statements).ToArray();
+ string fingerprint = Fingerprint(ast);
+ long retained = Retained(sources, editor);
+ times.Sort(); allocations.Sort();
+ reports.Add(new
+ {
+ fixture = multi ? "25-module" : "small",
+ editorCapture = editor,
+ sourceFiles = sources.Length,
+ sourceUtf16Bytes = sources.Sum(source => source.Length * sizeof(char)),
+ repetitionsPerSample = repetitions,
+ medianMilliseconds = times[samples / 2],
+ p90Milliseconds = times[(int)Math.Ceiling(samples * .9) - 1],
+ medianAllocatedBytes = allocations[samples / 2],
+ retainedHeapDeltaBytes = retained,
+ spanCount = last.Sum(parsed => parsed.Document.Spans.Count),
+#if BASELINE
+ syntaxRecords = 0,
+ syntaxEstimatedBytes = 0L,
+#else
+ syntaxRecords = last.Sum(parsed => parsed.Document.EditorSyntax?.Count ?? 0),
+ syntaxEstimatedBytes = last.Sum(parsed => parsed.Document.EditorSyntax?.EstimatedBytes ?? 0),
+#endif
+ resultFingerprint = fingerprint,
+ });
+ }
+}
+var report = new
+{
+ implementation = args[1],
+ timestampUtc = DateTime.UtcNow,
+ runtime = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription,
+ os = System.Runtime.InteropServices.RuntimeInformation.OSDescription,
+ architecture = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString(),
+ processorCount = Environment.ProcessorCount,
+ tieredCompilation = Environment.GetEnvironmentVariable("DOTNET_TieredCompilation") ?? "runtime default",
+ samples,
+ methodology = "Synchronous lexer + SourceDocument/line index + parser/normal lowerings per identical source sequence. JIT warmed; 31 batches; per-sequence latency/allocation divided by repetitions. No resolver, checker, I/O or cursor repair. Full public AST property graph fingerprint (tokens, type nodes, values and flags) computed outside timing, with no fields excluded. Retained heap is noisy full-GC live delta with final documents and AST roots alive; index estimate excludes base text/tokens/AST. Baseline archived without source instrumentation.",
+ reports,
+};
+File.WriteAllText(args[0], JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }));
+Console.WriteLine(args[0]);
+
+static List Parse(string[] sources, bool editor)
+{
+ var parsed = new List(sources.Length);
+ for (int index = 0; index < sources.Length; index++)
+ {
+ var document = new SourceDocument($"fixture{index}.ts", sources[index], isVirtual: true);
+ var parser = new Parser(new Lexer(document.Text).ScanTokens()).WithSourceDocument(document);
+#if !BASELINE
+ if (editor) parser.WithEditorSyntax();
+#endif
+ parsed.Add(new(document, parser.ParseOrThrow()));
+ }
+ return parsed;
+}
+
+[MethodImpl(MethodImplOptions.NoInlining)]
+static long Retained(string[] sources, bool editor)
+{
+ GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
+ long before = GC.GetTotalMemory(forceFullCollection: true);
+ var held = Parse(sources, editor);
+ long after = GC.GetTotalMemory(forceFullCollection: true);
+ GC.KeepAlive(held);
+ return after - before;
+}
+
+static string CreateSource(int index) => $$"""
+ {{(index == 0 ? "" : $"import {{ Box as Previous }} from './fixture{index - 1}';")}}
+ export interface Shape { value: T; update(next: T): void; }
+ export type Nested = Array>>;
+ export type Choice = T extends string ? { text: T } : { count: number };
+ export class Box implements Shape {
+ #serial: number = {{index}};
+ value: T;
+ constructor(value: T) { this.value = value; }
+ update(next: T): void { this.value = next; this.#serial = this.#serial + 1; }
+ map(f: (value: T) => U): Box { return new Box(f(this.value)); }
+ }
+ export function run{{index}}(input: number, label: string = "😀"): number {
+ const box = new Box(input);
+ const mapped = box.map((value: number): string => `${label}:${value}`);
+ const table = { result: mapped.value, [label]: input + 1 };
+ if (input > 0) { box.update(input * 2); }
+ return box.value + table[label] + (input ?? 0);
+ }
+ """;
+
+static string Fingerprint(object root)
+{
+ var result = new StringBuilder();
+ var visited = new Dictionary(ReferenceEqualityComparer.Instance);
+ Write(root);
+ return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(result.ToString())));
+
+ void Write(object? value)
+ {
+ if (value is null) { result.Append("null;"); return; }
+ Type type = value.GetType();
+ if (value is string text) { result.Append(text.Length).Append(':').Append(text).Append(';'); return; }
+ if (type.IsPrimitive || type.IsEnum || value is decimal or System.Numerics.BigInteger)
+ { result.Append(type.Name).Append(':').Append(Convert.ToString(value, CultureInfo.InvariantCulture)).Append(';'); return; }
+ if (visited.TryGetValue(value, out int identity)) { result.Append('@').Append(identity).Append(';'); return; }
+ visited[value] = visited.Count;
+ result.Append(type.FullName).Append('{');
+ if (value is IEnumerable sequence)
+ foreach (object? element in sequence) Write(element);
+ else
+ foreach (PropertyInfo property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
+ .Where(property => property.GetIndexParameters().Length == 0).OrderBy(property => property.Name, StringComparer.Ordinal))
+ {
+ result.Append(property.Name).Append('='); Write(property.GetValue(value));
+ }
+ result.Append('}');
+ }
+}
+
+sealed record Parsed(SourceDocument Document, IReadOnlyList Statements);
diff --git a/benchmarks/editor-syntax/README.md b/benchmarks/editor-syntax/README.md
new file mode 100644
index 000000000..9bc79485a
--- /dev/null
+++ b/benchmarks/editor-syntax/README.md
@@ -0,0 +1,39 @@
+# Editor syntax capture benchmark
+
+Run from the repository root with `pwsh -File benchmarks/editor-syntax/run.ps1`.
+An optional `-PackageCache ` uses an existing offline NuGet cache.
+The script archives exact baseline `2a613147` under ignored `artifacts/`, without
+editing baseline source, builds separate Release libraries/runners, and rejects
+any AST fingerprint mismatch between baseline, current ordinary parsing and
+current opt-in editor parsing. The runner is intentionally outside the solution.
+
+Both deterministic fixtures cover generic types, declarations, classes, private
+members, nested calls, parameter/default/body views, arrows, template strings,
+computed property keys and UTF-16 strings. The second fixture has 25 modules;
+this measures parsing their source sequence, without module resolution/checking.
+Each sample includes lexing, document/line-index creation, parsing and normal
+parser transforms. The script disables tiered compilation in measurement child
+processes to avoid background compilation during short batches. After JIT warmup,
+31 repeated batches report median/p90 time
+and current-thread allocations per sequence. Complete public AST graphs are
+fingerprinted outside timing, including tokens, type nodes, scalar values,
+flags, child ordering and reference sharing. No properties are excluded.
+
+Retained heap is a noisy full-GC live delta with one final source/AST sequence
+alive. `syntaxEstimatedBytes` is the index estimate used for editor cache bounds,
+and excludes the existing document, tokens and AST. This microbenchmark does
+not estimate end-to-end editor request latency or cursor recovery cost.
+
+The [2026-10-07 report](results-2026-10-07.json) has exact matching AST fingerprints
+for all modes on .NET 10.0.12, Windows ARM64. Ordinary parse medians increase 2.5%
+and 6.2% against `2a613147`, with about 0.31% more allocations. Enabled editor
+capture costs 4.58×/4.93× current ordinary latency and 2.45×/2.44× allocations,
+adding 0.190ms/7.663ms for the small/25-module sequences. It retains approximately
+50KB/1.15MB more complete document/AST heap (3.19×/2.97× total retained heap).
+
+Inspection identifies provenance collection, reachability, pruning, immutable
+copies/sorts and frozen indexes as the enabled-only work. The benchmark does not
+profile those phases separately. Additional allocations stay near 762 bytes per
+record across both fixtures. These costs occur on cold editor parsing; checked
+snapshots reuse the published index. Epic issue #1981 will measure end-to-end
+interactive latency and memory rather than infer them from this microbenchmark.
diff --git a/benchmarks/editor-syntax/results-2026-10-07.json b/benchmarks/editor-syntax/results-2026-10-07.json
new file mode 100644
index 000000000..a188d336a
--- /dev/null
+++ b/benchmarks/editor-syntax/results-2026-10-07.json
@@ -0,0 +1,151 @@
+{
+ "baseline": {
+ "implementation": "2a613147",
+ "timestampUtc": "2026-10-07T05:58:47.223149Z",
+ "runtime": ".NET 10.0.12",
+ "os": "Microsoft Windows 10.0.26220",
+ "architecture": "Arm64",
+ "processorCount": 12,
+ "tieredCompilation": "0",
+ "samples": 31,
+ "methodology": "Synchronous lexer + SourceDocument/line index + parser/normal lowerings per identical source sequence. JIT warmed; 31 batches; per-sequence latency/allocation divided by repetitions. No resolver, checker, I/O or cursor repair. Full public AST property graph fingerprint (tokens, type nodes, values and flags) computed outside timing, with no fields excluded. Retained heap is noisy full-GC live delta with final documents and AST roots alive; index estimate excludes base text/tokens/AST. Baseline archived without source instrumentation.",
+ "reports": [
+ {
+ "fixture": "small",
+ "editorCapture": false,
+ "sourceFiles": 1,
+ "sourceUtf16Bytes": 1670,
+ "repetitionsPerSample": 40,
+ "medianMilliseconds": 0.051785000000000005,
+ "p90Milliseconds": 0.061645000000000005,
+ "medianAllocatedBytes": 145737,
+ "retainedHeapDeltaBytes": 23000,
+ "spanCount": 21,
+ "syntaxRecords": 0,
+ "syntaxEstimatedBytes": 0,
+ "resultFingerprint": "674AA8C36512C0A2F118B2A4B54011FD0EFD369C39D4FFFB5CE09C53FF47F91D"
+ },
+ {
+ "fixture": "25-module",
+ "editorCapture": false,
+ "sourceFiles": 25,
+ "sourceUtf16Bytes": 43998,
+ "repetitionsPerSample": 3,
+ "medianMilliseconds": 1.8362999999999998,
+ "p90Milliseconds": 2.0738,
+ "medianAllocatedBytes": 3677061,
+ "retainedHeapDeltaBytes": 585560,
+ "spanCount": 549,
+ "syntaxRecords": 0,
+ "syntaxEstimatedBytes": 0,
+ "resultFingerprint": "CDE4BF603096B605C51C81F06DBC955DDE9FA2AA24E9E91072C335F9C0C5B62C"
+ }
+ ]
+ },
+ "current": {
+ "implementation": "editor-syntax-current",
+ "timestampUtc": "2026-10-07T05:58:50.9047042Z",
+ "runtime": ".NET 10.0.12",
+ "os": "Microsoft Windows 10.0.26220",
+ "architecture": "Arm64",
+ "processorCount": 12,
+ "tieredCompilation": "0",
+ "samples": 31,
+ "methodology": "Synchronous lexer + SourceDocument/line index + parser/normal lowerings per identical source sequence. JIT warmed; 31 batches; per-sequence latency/allocation divided by repetitions. No resolver, checker, I/O or cursor repair. Full public AST property graph fingerprint (tokens, type nodes, values and flags) computed outside timing, with no fields excluded. Retained heap is noisy full-GC live delta with final documents and AST roots alive; index estimate excludes base text/tokens/AST. Baseline archived without source instrumentation.",
+ "reports": [
+ {
+ "fixture": "small",
+ "editorCapture": false,
+ "sourceFiles": 1,
+ "sourceUtf16Bytes": 1670,
+ "repetitionsPerSample": 40,
+ "medianMilliseconds": 0.053095,
+ "p90Milliseconds": 0.05888,
+ "medianAllocatedBytes": 146185,
+ "retainedHeapDeltaBytes": 23016,
+ "spanCount": 21,
+ "syntaxRecords": 0,
+ "syntaxEstimatedBytes": 0,
+ "resultFingerprint": "674AA8C36512C0A2F118B2A4B54011FD0EFD369C39D4FFFB5CE09C53FF47F91D"
+ },
+ {
+ "fixture": "small",
+ "editorCapture": true,
+ "sourceFiles": 1,
+ "sourceUtf16Bytes": 1670,
+ "repetitionsPerSample": 40,
+ "medianMilliseconds": 0.243335,
+ "p90Milliseconds": 0.25878,
+ "medianAllocatedBytes": 357521,
+ "retainedHeapDeltaBytes": 73368,
+ "spanCount": 111,
+ "syntaxRecords": 277,
+ "syntaxEstimatedBytes": 26816,
+ "resultFingerprint": "674AA8C36512C0A2F118B2A4B54011FD0EFD369C39D4FFFB5CE09C53FF47F91D"
+ },
+ {
+ "fixture": "25-module",
+ "editorCapture": false,
+ "sourceFiles": 25,
+ "sourceUtf16Bytes": 43998,
+ "repetitionsPerSample": 3,
+ "medianMilliseconds": 1.9499666666666666,
+ "p90Milliseconds": 2.206566666666667,
+ "medianAllocatedBytes": 3688261,
+ "retainedHeapDeltaBytes": 585960,
+ "spanCount": 549,
+ "syntaxRecords": 0,
+ "syntaxEstimatedBytes": 0,
+ "resultFingerprint": "CDE4BF603096B605C51C81F06DBC955DDE9FA2AA24E9E91072C335F9C0C5B62C"
+ },
+ {
+ "fixture": "25-module",
+ "editorCapture": true,
+ "sourceFiles": 25,
+ "sourceUtf16Bytes": 43998,
+ "repetitionsPerSample": 3,
+ "medianMilliseconds": 9.613166666666666,
+ "p90Milliseconds": 10.242266666666668,
+ "medianAllocatedBytes": 8996453,
+ "retainedHeapDeltaBytes": 1740368,
+ "spanCount": 2799,
+ "syntaxRecords": 6973,
+ "syntaxEstimatedBytes": 675008,
+ "resultFingerprint": "CDE4BF603096B605C51C81F06DBC955DDE9FA2AA24E9E91072C335F9C0C5B62C"
+ }
+ ]
+ },
+ "comparisons": [
+ {
+ "fixture": "small",
+ "ordinaryVsBaselineTimePercent": 2.5296900646905485,
+ "ordinaryVsBaselineAllocatedPercent": 0.30740306167960085,
+ "enabledVsOrdinaryTimeMultiplier": 4.583011583011583,
+ "enabledVsOrdinaryAllocatedMultiplier": 2.445675000855081,
+ "enabledAdditionalAllocatedBytes": 211336,
+ "enabledVsOrdinaryRetainedMultiplier": 3.1876955161626697,
+ "enabledAdditionalRetainedBytes": 50352,
+ "enabledAdditionalMilliseconds": 0.19024,
+ "enabledAdditionalAllocatedBytesPerRecord": 762.9458483754513
+ },
+ {
+ "fixture": "25-module",
+ "ordinaryVsBaselineTimePercent": 6.1899834812757515,
+ "ordinaryVsBaselineAllocatedPercent": 0.3045910851084521,
+ "enabledVsOrdinaryTimeMultiplier": 4.929913331851827,
+ "enabledVsOrdinaryAllocatedMultiplier": 2.4392126804475063,
+ "enabledAdditionalAllocatedBytes": 5308192,
+ "enabledVsOrdinaryRetainedMultiplier": 2.9701140009556966,
+ "enabledAdditionalRetainedBytes": 1154408,
+ "enabledAdditionalMilliseconds": 7.6632,
+ "enabledAdditionalAllocatedBytesPerRecord": 761.2493905062383
+ }
+ ],
+ "investigation": [
+ "Ordinary parsing stays below the 10% investigation threshold on both fixtures; allocations increase about 0.31%.",
+ "Enabled capture has a measured 4.58x/4.93x parse-pipeline latency and 2.45x/2.44x allocations relative to current ordinary parsing; absolute additional latency is 0.19024ms/7.6632ms.",
+ "Code inspection attributes enabled-only work to provenance collection/deduplication, final AST/type/attachment reachability, span pruning, immutable array copies/sorts and frozen reference-keyed node indexes. This is attribution by inspection, not measured phase profiling.",
+ "Extra allocated bytes per syntax record remain approximately constant across the fixtures (763B/761B), consistent with bounded linear data growth rather than a new allocation scaling problem.",
+ "Retained heap covers complete final documents and AST roots; the editor index estimate covers index structures only. Full-GC heap deltas are noisy. Editor metadata is captured on a cold parse and reused by checked snapshots; end-to-end interactive budgets will be evaluated by #1981."
+ ]
+}
\ No newline at end of file
diff --git a/benchmarks/editor-syntax/run.ps1 b/benchmarks/editor-syntax/run.ps1
new file mode 100644
index 000000000..f373a6f41
--- /dev/null
+++ b/benchmarks/editor-syntax/run.ps1
@@ -0,0 +1,51 @@
+param(
+ [string]$BaselineCommit = '2a613147',
+ [string]$PackageCache = '',
+ [string]$OutputDirectory = ''
+)
+$ErrorActionPreference = 'Stop'
+$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
+if (!$OutputDirectory) { $OutputDirectory = Join-Path $repoRoot 'artifacts/editor-syntax-benchmark' }
+$outputRoot = [IO.Path]::GetFullPath($OutputDirectory)
+New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null
+$baselineRoot = Join-Path $outputRoot ('baseline-' + $BaselineCommit)
+$baselineArchive = Join-Path $outputRoot ('baseline-' + $BaselineCommit + '.zip')
+if (!(Test-Path -LiteralPath $baselineRoot)) {
+ & git -C $repoRoot archive --format=zip "--output=$baselineArchive" $BaselineCommit
+ if ($LASTEXITCODE) { throw 'git archive failed.' }
+ Expand-Archive -LiteralPath $baselineArchive -DestinationPath $baselineRoot
+}
+function Invoke-CheckedDotNet([string[]]$DotNetArguments) {
+ & dotnet @DotNetArguments
+ if ($LASTEXITCODE) { throw "dotnet failed: $($DotNetArguments[0])" }
+}
+$restoreOptions = @('-p:NuGetAudit=false', '-p:MinVerVersionOverride=0.0.0-benchmark')
+if ($PackageCache) {
+ $emptyFeed = Join-Path $outputRoot 'empty-feed'
+ New-Item -ItemType Directory -Path $emptyFeed -Force | Out-Null
+ $restoreOptions += @("-p:RestorePackagesPath=$([IO.Path]::GetFullPath($PackageCache))", '--source', $emptyFeed)
+}
+Invoke-CheckedDotNet (@('build', (Join-Path $baselineRoot 'src/SharpTS/SharpTS.csproj'), '-c', 'Release', '-v', 'minimal') + $restoreOptions)
+Invoke-CheckedDotNet (@('build', (Join-Path $repoRoot 'src/SharpTS/SharpTS.csproj'), '-c', 'Release', '-v', 'minimal') + $restoreOptions)
+foreach ($implementation in @('baseline', 'current')) {
+ $libraryRoot = if ($implementation -eq 'baseline') { $baselineRoot } else { $repoRoot }
+ $runnerOptions = @('build', (Join-Path $PSScriptRoot 'EditorSyntax.csproj'), '-c', 'Release', '-v', 'minimal',
+ "-p:LibraryDirectory=$libraryRoot/src/SharpTS/bin/Release/net10.0",
+ "-p:BaseOutputPath=$outputRoot/bin/$implementation/", "-p:BaseIntermediateOutputPath=$outputRoot/obj/$implementation/")
+ if ($implementation -eq 'baseline') { $runnerOptions += '-p:Baseline=true' }
+ Invoke-CheckedDotNet ($runnerOptions + $restoreOptions)
+ $label = if ($implementation -eq 'baseline') { $BaselineCommit } else { 'editor-syntax-current' }
+ $previousBenchmarkTiering = $env:DOTNET_TieredCompilation
+ try {
+ $env:DOTNET_TieredCompilation = '0'
+ Invoke-CheckedDotNet @((Join-Path $outputRoot "bin/$implementation/Release/net10.0/SharpTS.SyntaxBenchmark.dll"),
+ (Join-Path $outputRoot "$implementation.json"), $label)
+ } finally { $env:DOTNET_TieredCompilation = $previousBenchmarkTiering }
+}
+$before = Get-Content -LiteralPath (Join-Path $outputRoot 'baseline.json') -Raw | ConvertFrom-Json
+$after = Get-Content -LiteralPath (Join-Path $outputRoot 'current.json') -Raw | ConvertFrom-Json
+foreach ($report in $after.reports) {
+ $original = $before.reports | Where-Object fixture -eq $report.fixture
+ if ($original.resultFingerprint -ne $report.resultFingerprint) { throw "AST mismatch: $($report.fixture), capture=$($report.editorCapture)" }
+}
+Write-Output "Matching ASTs. Reports: $outputRoot/baseline.json and current.json"
diff --git a/benchmarks/editor-workflow/EditorWorkflow.csproj b/benchmarks/editor-workflow/EditorWorkflow.csproj
new file mode 100644
index 000000000..7b3df6e4e
--- /dev/null
+++ b/benchmarks/editor-workflow/EditorWorkflow.csproj
@@ -0,0 +1,14 @@
+
+
+ net10.0
+ Exe
+ SharpTS.Tests
+ enable
+ enable
+ latest
+ false
+
+
+
+
+
diff --git a/benchmarks/editor-workflow/Program.cs b/benchmarks/editor-workflow/Program.cs
new file mode 100644
index 000000000..74d95f873
--- /dev/null
+++ b/benchmarks/editor-workflow/Program.cs
@@ -0,0 +1,449 @@
+using System.Collections;
+using System.Diagnostics;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using OmniSharp.Extensions.LanguageServer.Protocol;
+using OmniSharp.Extensions.LanguageServer.Protocol.Models;
+using SharpTS.LanguageServer;
+using SharpTS.LanguageServer.Project;
+using SharpTS.LanguageServer.Services;
+using SharpTS.Parsing;
+
+const int coldSamples = 11, warmSamples = 31;
+string outputRoot = Path.GetFullPath(args[0]);
+Directory.CreateDirectory(outputRoot);
+var reports = new List();
+foreach (string name in new[] { "small", "multi-project", "default-library" })
+{
+ Fixture fixture = Fixture.Create(Path.Combine(outputRoot, "fixtures", name), name);
+ using (var prewarm = new Session(fixture))
+ for (int i = 0; i < 3; i++) await prewarm.Sequence();
+ var cold = new List();
+ var construction = new List<(double Milliseconds, long AllocatedBytes)>();
+ int count = name == "default-library" ? 3 : coldSamples;
+ for (int i = 0; i < count; i++)
+ {
+ long allocated = GC.GetTotalAllocatedBytes(true);
+ var constructing = Stopwatch.StartNew();
+ using var session = new Session(fixture);
+ constructing.Stop();
+ construction.Add((constructing.Elapsed.TotalMilliseconds, GC.GetTotalAllocatedBytes(true) - allocated));
+ cold.Add(await Measure(session, session.Sequence));
+ }
+ var warm = new List();
+ var edits = new List();
+ object memory;
+ AnalysisStatistics retained;
+ int documentCount;
+ using (var session = new Session(fixture))
+ {
+ long emptyHeap = CollectHeap();
+ await session.Sequence();
+ for (int i = 0; i < (name == "default-library" ? 7 : warmSamples); i++)
+ warm.Add(await Measure(session, session.Sequence));
+ retained = session.Analysis.Statistics;
+ documentCount = session.CountDocuments();
+ long activeHeap = CollectHeap();
+ session.Analysis.InvalidateAll();
+ long clearedHeap = CollectHeap();
+ Require(session.Analysis.Statistics.RetainedSnapshots == 0, "InvalidateAll retained completed graphs");
+ memory = new { emptySessionManagedHeap = emptyHeap, activeCacheManagedHeap = activeHeap,
+ afterInvalidationManagedHeap = clearedHeap, activeMinusEmptyBytes = activeHeap - emptyHeap,
+ activeMinusInvalidatedBytes = activeHeap - clearedHeap };
+ for (int i = 0; i < 7; i++)
+ {
+ session.SetMain(fixture.Text + $"\n// open version {i}\n");
+ edits.Add(await Measure(session, session.Sequence));
+ }
+ }
+ Require(warm.All(sample => sample.Checks == 0), $"{name}: unchanged warm sequence rechecked");
+ Require(cold.Concat(warm).Select(sample => sample.Fingerprint).Distinct().Count() == 1,
+ $"{name}: cold/warm result fingerprint changed");
+ object? dependency = null;
+ if (fixture.Dependency is not null)
+ {
+ using var session = new Session(fixture);
+ await session.Sequence();
+ var changed = new List();
+ for (int i = 0; i < 7; i++)
+ {
+ session.Store.Set(new Uri(fixture.Dependency).AbsoluteUri,
+ Fixture.BoxSource.Replace("value = 1", $"value = {i + 2}", StringComparison.Ordinal));
+ changed.Add(await Measure(session, session.Sequence));
+ }
+ dependency = Summary(changed);
+ }
+ reports.Add(new { name, fixture.SourceFiles, fixture.Projects, fixture.ExpectedReferences,
+ freshServiceConstruction = new {
+ medianMilliseconds = construction.Select(sample => sample.Milliseconds).Order().ElementAt(construction.Count / 2),
+ p90Milliseconds = construction.Select(sample => sample.Milliseconds).Order().ElementAt((int)Math.Ceiling(construction.Count * .9) - 1),
+ medianAllocatedBytes = construction.Select(sample => sample.AllocatedBytes).Order().ElementAt(construction.Count / 2) },
+ sourceDocumentsInEntrySnapshot = documentCount, cold = Summary(cold), warm = Summary(warm),
+ changedOpenVersion = Summary(edits), changedOpenDependency = dependency,
+ retained.RetainedSnapshots, retained.EstimatedRetainedBytes, memory });
+}
+Fixture recoveryFixture = Fixture.Create(Path.Combine(outputRoot, "fixtures", "recovery"), "small");
+object recovery;
+using (var session = new Session(recoveryFixture))
+{
+ var kinds = new List();
+ foreach (bool signature in new[] { false, true })
+ {
+ string suffix = signature ? "\ncombine(1, " : "\nbox. ";
+ session.SetMain(recoveryFixture.Text + suffix);
+ int start = (recoveryFixture.Text + suffix).Length - 12;
+ var first = await Measure(session, () => session.Recovery(start, signature));
+ var same = new List();
+ for (int i = 0; i < 15; i++) same.Add(await Measure(session, () => session.Recovery(start, signature)));
+ Require(same.All(sample => sample.Checks == 0), "same recovery caret rechecked");
+ Require(same.All(sample => sample.Fingerprint == first.Fingerprint), "same recovery caret changed its result");
+ var changed = new List();
+ for (int i = 1; i <= 8; i++)
+ changed.Add(await Measure(session, () => session.Recovery(start + i, signature)));
+ Require(changed.All(sample => sample.Checks > 0), "changed recovery caret unexpectedly reused another caret's graph");
+ var changedVersion = new List();
+ for (int i = 0; i < 7; i++)
+ {
+ string edited = recoveryFixture.Text + $"\n// recovery edit {i}\n" + suffix;
+ session.SetMain(edited);
+ changedVersion.Add(await Measure(session, () => session.Recovery(edited.Length - 12, signature)));
+ }
+ Require(changedVersion.All(sample => sample.Checks > 0), "changed recovery version reused old semantics");
+ var cache = Inspect(session.Analysis);
+ Require(cache.CursorEntries <= 4 && cache.RetainedEntries <= 8, "cursor retention exceeded defaults");
+ kinds.Add(new { kind = signature ? "signature-help" : "member-completion", first,
+ unchangedCaret = Summary(same), changedCaret = Summary(changed), changedVersion = Summary(changedVersion), cache });
+ }
+ recovery = kinds;
+}
+object burst = await RunBurst(Fixture.Create(Path.Combine(outputRoot, "fixtures", "burst"), "small"));
+object metadata = await RunMetadata(Fixture.Create(Path.Combine(outputRoot, "fixtures", "metadata"), "small"));
+object retention = RunRetention(Fixture.Create(Path.Combine(outputRoot, "fixtures", "retention"), "small"));
+var report = new { timestampUtc = DateTime.UtcNow, sourceCommit = args.Length > 2 ? args[2] : "unspecified",
+ binaries = new { languageServerSha256 = HashFile(typeof(SemanticAnalysisService).Assembly.Location),
+ compilerSha256 = HashFile(typeof(SharpTS.TypeSystem.BindingIndex).Assembly.Location) },
+ runtime = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription,
+ os = System.Runtime.InteropServices.RuntimeInformation.OSDescription,
+ architecture = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString(),
+ coldSamples, warmSamples,
+ methodology = "Current-version actual service sequence. Cold means empty service cache in a JIT/OS-cache-warmed process; warm means identical captured overlays and caret. All query currentness checks are included. Allocations are process-wide across async workers. Payload serialization/fingerprinting follows the measured window. Managed heap is a full-GC observation, not a cache/process limit. No prior equivalent for new features; earlier exact-baseline reports remain separate.",
+ reports, recovery, burst, metadata, retention };
+string output = Path.Combine(outputRoot, args.Length > 1 ? args[1] : "results.json");
+await File.WriteAllTextAsync(output, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }));
+Console.WriteLine(output);
+
+static void Require(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); }
+static string HashFile(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant();
+static long CollectHeap() { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); return GC.GetTotalMemory(true); }
+static async Task Measure(Session session, Func> query)
+{
+ long checks = session.Analysis.Statistics.Checks, builds = session.Analysis.Statistics.Builds;
+ long allocated = GC.GetTotalAllocatedBytes(true);
+ var watch = Stopwatch.StartNew();
+ object response = await query();
+ watch.Stop();
+ long allocationDelta = GC.GetTotalAllocatedBytes(true) - allocated;
+ byte[] bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(response).Replace(session.Fixture.Root.Replace('\\', '/'), "", StringComparison.OrdinalIgnoreCase)
+ .Replace(session.Fixture.Root.Replace("\\", "\\\\"), "", StringComparison.OrdinalIgnoreCase));
+ return new(watch.Elapsed.TotalMilliseconds, allocationDelta, session.Analysis.Statistics.Checks - checks,
+ session.Analysis.Statistics.Builds - builds, Convert.ToHexString(SHA256.HashData(bytes)), bytes.Length);
+}
+static object Summary(List samples) => new {
+ sampleCount = samples.Count,
+ medianMilliseconds = samples.Select(sample => sample.Milliseconds).Order().ElementAt(samples.Count / 2),
+ p90Milliseconds = samples.Select(sample => sample.Milliseconds).Order().ElementAt((int)Math.Ceiling(samples.Count * .9) - 1),
+ medianAllocatedBytes = samples.Select(sample => sample.AllocatedBytes).Order().ElementAt(samples.Count / 2),
+ checks = samples.Select(sample => sample.Checks).Distinct().Order().ToArray(),
+ builds = samples.Select(sample => sample.Builds).Distinct().Order().ToArray(),
+ fingerprints = samples.Select(sample => sample.Fingerprint).Distinct().ToArray(),
+ payloadBytes = samples.Select(sample => sample.PayloadBytes).Distinct().Order().ToArray(), samples };
+
+// Runner-only inspection under the actual service gate; no public protocol/counter is added.
+static CacheObservation Inspect(SemanticAnalysisService analysis)
+{
+ object Read(string name) => typeof(SemanticAnalysisService).GetField(name, BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(analysis)!;
+ lock (Read("_gate"))
+ {
+ var inflight = (IDictionary)Read("_inflight");
+ var lru = (IEnumerable)Read("_lru");
+ int cursor = 0;
+ foreach (object entry in lru)
+ {
+ var keys = (IEnumerable)entry.GetType().GetProperty("Keys")!.GetValue(entry)!;
+ if (keys.Cast().Any(key => key.GetType().GetProperty("CursorTarget")!.GetValue(key) is not null)) cursor++;
+ }
+ return new(inflight.Count, ((SemaphoreSlim)Read("_admission")).CurrentCount,
+ ((SemaphoreSlim)Read("_buildSlots")).CurrentCount, analysis.Statistics.RetainedSnapshots,
+ cursor, analysis.Statistics.EstimatedRetainedBytes);
+ }
+}
+
+static async Task RunBurst(Fixture fixture)
+{
+ using var session = new Session(fixture);
+ using var release = new ManualResetEventSlim(false);
+ int entered = 0;
+ session.Analysis.BeforeCheck = () => { Interlocked.Increment(ref entered); release.Wait(TimeSpan.FromSeconds(20)); };
+ var sources = Enumerable.Range(0, 24).Select(_ => new CancellationTokenSource()).ToArray();
+ var tasks = new List>();
+ try
+ {
+ for (int i = 0; i < sources.Length; i++)
+ {
+ session.SetMain(fixture.Text + $"\n// obsolete {i}\n");
+ DocumentRequestSnapshot capture = session.Capture;
+ CancellationToken token = sources[i].Token;
+ tasks.Add(Wait());
+ async Task Wait()
+ {
+ try { using var lease = await session.Analysis.GetDocumentAsync(capture, token); return false; }
+ catch (OperationCanceledException) { return true; }
+ }
+ }
+ var deadline = Stopwatch.StartNew();
+ CacheObservation blocked;
+ do { blocked = Inspect(session.Analysis); if (blocked.Inflight == 16 && Volatile.Read(ref entered) == 2) break; await Task.Delay(5); }
+ while (deadline.Elapsed < TimeSpan.FromSeconds(15));
+ Require(blocked.Inflight == 16 && entered == 2, "burst did not reach the configured build/admission bounds");
+ foreach (var source in sources) source.Cancel();
+ var settling = Stopwatch.StartNew();
+ bool[] settled = await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(15));
+ settling.Stop();
+ Require(settled.All(value => value), "gated obsolete requests did not cancel");
+ session.Analysis.BeforeCheck = null;
+ release.Set();
+ session.SetMain(fixture.Text + "\n// final fresh request\n");
+ Sample fresh = await Measure(session, session.Sequence);
+ while (deadline.Elapsed < TimeSpan.FromSeconds(30))
+ {
+ var observed = Inspect(session.Analysis);
+ if (observed.Inflight == 0 && observed.AdmissionAvailable == 16 && observed.BuildSlotsAvailable == 2) break;
+ await Task.Delay(5);
+ }
+ CacheObservation completed = Inspect(session.Analysis);
+ Require(completed.Inflight == 0 && completed.AdmissionAvailable == 16 && completed.BuildSlotsAvailable == 2,
+ "burst did not release admission/build slots");
+ Require(completed.RetainedEntries <= 8 && completed.CursorEntries <= 4, "burst retained too many completed entries");
+ return new { requests = tasks.Count, canceledWaiters = settled.Count(value => value), blocked,
+ cancellationSettlementMilliseconds = settling.Elapsed.TotalMilliseconds,
+ checkedBuildsEnteringGate = entered, fresh, completed, statistics = session.Analysis.Statistics };
+ }
+ finally
+ {
+ session.Analysis.BeforeCheck = null;
+ release.Set();
+ foreach (var source in sources) { source.Cancel(); source.Dispose(); }
+ }
+}
+
+[MethodImpl(MethodImplOptions.NoInlining)]
+static object RunRetention(Fixture fixture)
+{
+ using var session = new Session(fixture);
+ long empty = CollectHeap();
+ Fill();
+ CacheObservation cached = Inspect(session.Analysis);
+ Require(cached.RetainedEntries <= 8 && cached.CursorEntries <= 4 && cached.EstimatedBytes <= 64 * 1024 * 1024,
+ "multiple-document recovery exceeded completed-cache bounds");
+ long active = CollectHeap();
+ session.Analysis.InvalidateAll();
+ long invalidated = CollectHeap();
+ Require(session.Analysis.Statistics.RetainedSnapshots == 0, "retention invalidation did not empty cache");
+ return new { documents = 12, cursorRequests = 24, cached, emptyManagedHeap = empty,
+ activeManagedHeap = active, invalidatedManagedHeap = invalidated, releasedManagedHeapObservation = active - invalidated };
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ void Fill()
+ {
+ for (int document = 0; document < 12; document++)
+ {
+ string path = Path.Combine(fixture.Root, $"document{document}", "entry.ts");
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(Path.Combine(Path.GetDirectoryName(path)!, "tsconfig.json"),
+ "{\"compilerOptions\":{\"noLib\":true,\"types\":[]},\"files\":[\"entry.ts\"]}");
+ const string text = "class Box { value = 1; } const box = new Box(); box. ";
+ File.WriteAllText(path, text);
+ string uri = new Uri(path).AbsoluteUri;
+ session.Store.Open(uri, text, 1);
+ if (!session.Store.TryCapture(uri, out var capture)) throw new InvalidOperationException();
+ using var seed = session.Analysis.GetDocumentAsync(capture).GetAwaiter().GetResult();
+ for (int caret = text.Length - 2; caret < text.Length; caret++)
+ {
+ using var cursor = session.Analysis.GetCursorDocumentAsync(capture, caret, EditorQueryKind.Completion,
+ seed, CancellationToken.None).GetAwaiter().GetResult();
+ Require(cursor is not null, "retention cursor build unavailable");
+ }
+ }
+ }
+}
+
+static async Task RunMetadata(Fixture fixture)
+{
+ string reference = Path.Combine(fixture.Root, "reference.dll");
+ string first = typeof(SharpTS.TypeSystem.BindingIndex).Assembly.Location;
+ string second = typeof(SemanticAnalysisService).Assembly.Location;
+ File.Copy(first, reference, overwrite: true);
+ using var provider = new AnalysisMetadataProvider(references: [reference], startDirectory: fixture.Root);
+ using var session = new Session(fixture, provider);
+ await session.Sequence();
+ var unchanged = new List();
+ for (int i = 0; i < 7; i++) unchanged.Add(await Measure(session, session.Sequence));
+ int originalGeneration = provider.RefreshGeneration();
+ var refreshed = new List();
+ var generations = new List();
+ for (int i = 0; i < 7; i++)
+ {
+ File.Copy(i % 2 == 0 ? second : first, reference, overwrite: true);
+ refreshed.Add(await Measure(session, session.Sequence));
+ generations.Add(provider.RefreshGeneration());
+ }
+ Require(unchanged.All(sample => sample.Checks == 0), "unchanged CLR generation rechecked");
+ Require(refreshed.All(sample => sample.Checks > 0), "changed CLR generation reused old checking");
+ Require(unchanged.Concat(refreshed).Select(sample => sample.Fingerprint).Distinct().Count() == 1,
+ "unrelated CLR reference replacement changed ordinary query output");
+ Require(generations.Distinct().Count() == 7 && generations[0] != originalGeneration, "CLR replacement did not refresh generations");
+ return new { referenceBytes = new FileInfo(reference).Length, originalGeneration, generations,
+ unchangedGeneration = Summary(unchanged), replacedAssembly = Summary(refreshed),
+ note = "Real custom CLR reference bytes alternate between core and LS assemblies; BCL remains available. Full metadata capture/hash/currentness cost is included, no restore or executable loading." };
+}
+
+record Sample(double Milliseconds, long AllocatedBytes, long Checks, long Builds, string Fingerprint, int PayloadBytes);
+record CacheObservation(int Inflight, int AdmissionAvailable, int BuildSlotsAvailable, int RetainedEntries, int CursorEntries, long EstimatedBytes);
+
+sealed class Session : IDisposable
+{
+ public Fixture Fixture { get; }
+ public DocumentStore Store { get; } = new();
+ public SemanticAnalysisService Analysis { get; }
+ readonly SemanticHoverService _hover;
+ readonly SemanticCompletionService _completion;
+ readonly SemanticSignatureHelpService _signature;
+ readonly DefinitionService _definitions;
+ readonly ReferenceService _references;
+ readonly RenameService _rename;
+ readonly PrivateRenameService _privateRename;
+ public Session(Fixture fixture, AnalysisMetadataProvider? metadata = null)
+ {
+ Fixture = fixture;
+ var workspace = new NavigationWorkspaceContext();
+ workspace.Initialize(new InitializeParams { RootUri = new Uri(fixture.Root).AbsoluteUri });
+ Analysis = new(workspace, metadata);
+ _hover = new(Analysis, metadata is null ? null : new MemberHoverService(metadata.Resolve));
+ _completion = new(Analysis); _signature = new(Analysis); _definitions = new(Analysis);
+ _references = new(Analysis); _rename = new(_references); _privateRename = new(Analysis);
+ Store.Open(new Uri(fixture.Path).AbsoluteUri, fixture.Text, 1);
+ }
+ public DocumentRequestSnapshot Capture
+ {
+ get { if (!Store.TryCapture(new Uri(Fixture.Path).AbsoluteUri, out var capture)) throw new InvalidOperationException(); return capture; }
+ }
+ public void SetMain(string text) => Store.Set(new Uri(Fixture.Path).AbsoluteUri, text);
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public int CountDocuments()
+ {
+ using var lease = Analysis.GetDocumentAsync(Capture).GetAwaiter().GetResult();
+ return lease?.Model.Snapshot.Documents.Count ?? 0;
+ }
+ public async Task Sequence()
+ {
+ DocumentRequestSnapshot capture = Capture;
+ var hover = await _hover.HoverAsync(capture, At(capture, "/*member*/", 1), MarkupKind.Markdown, default);
+ var completion = await _completion.CompletionAsync(capture, At(capture, "/*member*/", 2), default);
+ var signatures = await _signature.SignatureHelpAsync(capture, At(capture, "/*signature*/"), true, true, default);
+ var definitions = await _definitions.FindDefinitionsAsync(capture, At(capture, "/*member*/", 1), default);
+ var references = await _references.FindReferenceResultAsync(capture, At(capture, "/*member*/", 1), true, [Fixture.Root]);
+ var lexical = await _rename.PrepareAsync(capture, At(capture, "/*lexical*/", 1), [Fixture.Root], default);
+ var privateResult = await _privateRename.PrepareAsync(capture, At(capture, "/*private*/", 2), default);
+ if (hover.Hover?.Contents.MarkupContent?.Value != "```typescript\nvalue: any\n```" || !hover.IsCurrent() || !completion.List.Items.Any(item => item.Label == "value") ||
+ !completion.IsCurrent() || signatures.Help is null || !signatures.IsCurrent() || definitions.Locations.Count != 1 ||
+ !definitions.IsCurrent() || references.Locations.Count != Fixture.ExpectedReferences || !references.IsComplete ||
+ !references.IsCurrent() || lexical.Value is null || !lexical.Domain.IsCurrent() || privateResult.Value is null || !privateResult.IsCurrent())
+ throw new InvalidOperationException($"Workflow unavailable: hover={hover.Hover is not null}, completion={completion.List.Items.Count()}, sig={signatures.Help is not null}, defs={definitions.Locations.Count}, refs={references.Locations.Count}, lexical={lexical.Value is not null}, private={privateResult.Value is not null}.");
+ return new { hover = new { text = hover.Hover.Contents.MarkupContent?.Value, hover.Hover.Range },
+ completion = CompletionPayload(completion.List), signatures = SignaturePayload(signatures.Help),
+ definitions = Relative(definitions.Locations), references = Relative(references.Locations),
+ lexical = lexical.Value, privatePrepare = new { privateResult.Value.PlaceholderRange?.Range,
+ privateResult.Value.PlaceholderRange?.Placeholder } };
+ }
+ object[] Relative(IReadOnlyList locations) => locations.Select(location => (object)new {
+ path = Path.GetRelativePath(Fixture.Root, location.Uri.GetFileSystemPath()).Replace('\\', '/'), location.Range }).ToArray();
+ public async Task Recovery(int offset, bool signature)
+ {
+ DocumentRequestSnapshot capture = Capture;
+ var lines = new LineIndex(capture.Document.Text);
+ var (line, column) = lines.ToPosition(offset);
+ var position = new Position(line - 1, column - 1);
+ if (signature)
+ {
+ var result = await _signature.SignatureHelpAsync(capture, position, true, true, default);
+ if (result.Help is null || !result.IsCurrent()) throw new InvalidOperationException("Signature recovery unavailable");
+ return SignaturePayload(result.Help);
+ }
+ else
+ {
+ var result = await _completion.CompletionAsync(capture, position, default);
+ if (!result.List.Items.Any(item => item.Label == "value") || !result.IsCurrent()) throw new InvalidOperationException("Member recovery unavailable");
+ return CompletionPayload(result.List);
+ }
+ }
+ static object CompletionPayload(CompletionList list) => new { list.IsIncomplete,
+ items = list.Items.Select(item => new { item.Label, item.Kind, item.Detail,
+ range = item.TextEdit?.TextEdit?.Range, text = item.TextEdit?.TextEdit?.NewText }).ToArray() };
+ static object SignaturePayload(SignatureHelp help) => new { help.ActiveSignature, help.ActiveParameter,
+ signatures = help.Signatures.Select(signature => new { signature.Label, signature.ActiveParameter }).ToArray() };
+ static Position At(DocumentRequestSnapshot capture, string marker, int delta = 0)
+ {
+ int offset = capture.Document.Text.IndexOf(marker, StringComparison.Ordinal) + marker.Length + delta;
+ var (line, column) = new LineIndex(capture.Document.Text).ToPosition(offset);
+ return new(line - 1, column - 1);
+ }
+ public void Dispose() { _definitions.Dispose(); _references.Dispose(); Analysis.Dispose(); }
+}
+
+sealed record Fixture(string Root, string Path, string Text, string? Dependency, int SourceFiles, int Projects, int ExpectedReferences)
+{
+ public const string BoxSource = "export class Box { value = 1; }\n";
+ public static Fixture Create(string root, string name)
+ {
+ bool multi = name == "multi-project", libraries = name == "default-library";
+ void Write(string relative, string source)
+ {
+ string path = System.IO.Path.Combine(root, relative);
+ Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, source);
+ }
+ string options = libraries ? "\"compilerOptions\":{\"types\":[]}" : "\"compilerOptions\":{\"noLib\":true,\"types\":[]}";
+ string header = multi ? "import { Box } from '../../../packages/lib/src/box';\n" : BoxSource;
+ string text = header + "class Secret { #serial = 1; read() { return this./*private*/#serial; } }\n" +
+ "function combine(left: number, right: string): number { return left; }\n" +
+ "const box = new Box();\nconst local: number = 1;\nconst result = box./*member*/value;\n" +
+ "combine(/*signature*/local, 'text');\n/*lexical*/local;\n" +
+ (libraries ? "const libraryValues: Array = [1, 2];\nconst time = new Date();\n" : "");
+ string path, dependency;
+ if (multi)
+ {
+ Write("tsconfig.json", "{" + options + ",\"files\":[],\"references\":[{\"path\":\"packages/lib\"},{\"path\":\"apps/one\"},{\"path\":\"apps/two\"}]}");
+ Write("packages/lib/src/box.ts", BoxSource);
+ Write("packages/lib/tsconfig.json", "{" + options + ",\"include\":[\"src/**/*.ts\"]}");
+ foreach (string app in new[] { "one", "two" })
+ {
+ Write($"apps/{app}/tsconfig.json", "{" + options + ",\"include\":[\"src/**/*.ts\"],\"references\":[{\"path\":\"../../packages/lib\"}]}");
+ for (int file = 0; file < 12; file++)
+ Write($"apps/{app}/src/importer{file}.ts", app == "one" && file == 0 ? text :
+ "import { Box } from '../../../packages/lib/src/box';\nconst box = new Box();\nconst result = box.value;\n" +
+ string.Join("\n", Enumerable.Range(0, 30).Select(index => $"export function compute{index}(value: number): number {{ return value + result; }}")) + "\n");
+ }
+ path = System.IO.Path.Combine(root, "apps/one/src/importer0.ts");
+ dependency = System.IO.Path.Combine(root, "packages/lib/src/box.ts");
+ }
+ else
+ {
+ Write("main.ts", text); Write("tsconfig.json", "{" + options + ",\"files\":[\"main.ts\"]}");
+ path = System.IO.Path.Combine(root, "main.ts"); dependency = "";
+ }
+ return new(root, System.IO.Path.GetFullPath(path), text, multi ? dependency : null, multi ? 25 : 1, multi ? 4 : 1, multi ? 25 : 2);
+ }
+}
diff --git a/benchmarks/editor-workflow/README.md b/benchmarks/editor-workflow/README.md
new file mode 100644
index 000000000..a9508ba27
--- /dev/null
+++ b/benchmarks/editor-workflow/README.md
@@ -0,0 +1,187 @@
+# Editor workflow benchmark
+
+Optional #1981 replay, outside the solution and timing gates. Build the production Release
+language server first, then run this independent `SharpTS.Tests` friend assembly against
+those frozen DLLs. It adds no production instrumentation or server dependency.
+
+```powershell
+dotnet build src/SharpTS.LanguageServer/SharpTS.LanguageServer.csproj -c Release
+./benchmarks/editor-workflow/run.ps1 -PackageCache C:/Users/you/.nuget/packages
+# Prepare only, then run timings later with no overlapping builds/tests/editors:
+./benchmarks/editor-workflow/run.ps1 -BuildOnly -LibraryDirectory /absolute/frozen/LS/outputs
+./benchmarks/editor-workflow/run.ps1 -NoBuild -LibraryDirectory /absolute/frozen/LS/outputs
+```
+
+`-OutputDirectory` selects a disposable artifact directory. `-PackageCache` uses the existing
+package cache and an empty local feed for the runner's restore. `-NoStdio` skips the separate
+transport run. The script never builds production projects; do that explicitly before
+measurement. Run on an otherwise idle machine, and retain source/binary versions with results.
+The build has a 120-second process deadline and each measurement runner a 240-second
+process-tree cleanup deadline. These detect hangs and are not latency acceptance thresholds.
+Before measurements, copied runner LS/core hashes must match the selected library directory;
+`-NoBuild` refuses stale copies rather than mixing internal counters with another stdio binary.
+
+The service sequence uses actual hover, receiver completion, signature help, definition,
+member references including declarations, lexical rename preparation and private rename
+preparation APIs. Every returned validation is checked. The small fixture is one source/config;
+the larger fixture has 25 sources, four configurations, project references and closed reverse
+importers, matching the graph shape of the member-reference benchmark. A third small fixture
+loads the real default libraries and uses `Array` and `Date`. The first two fixtures
+keep `noLib: true` / `types: []` to isolate source graph costs.
+`sourceDocumentsInEntrySnapshot` counts published source documents in the entry component,
+not all workspace sources or embedded default declaration modules, which have no SourceDocument.
+The original raw report called this counter `checkedDocumentsIncludingLibraries`; the compact
+report corrects that label without changing its measured value.
+
+The original unannotated instance field `Box.value = 1` is deliberately retained. The current
+checker publishes `any` for that field: it checks the initializer without republishing an
+inferred instance field type. Both direct-service and actual-wire runs assert the exact
+`value: any` hover. This is a checker limitation, not a CLR-provider or diagnostics race.
+
+After three prewarm sequences, 11 cache-cold samples create fresh services in the warmed
+process; 31 warm samples reuse identical captures/carets. The default-library fixture
+uses three cold and seven warm samples to bound replay cost. Cold/warm result fingerprints
+must agree, and unchanged warm requests must perform zero whole-component checks. Seven
+changed-open-version sequences and seven dirty-dependency sequences are separate phases.
+They report actual rechecking rather than claiming incremental compiler updates.
+Fresh service construction is timed separately from its cache-cold query.
+
+Completion/signature recovery measures the first cursor graph, 15 identical-caret repeats,
+then eight distinct carets in the same buffer and seven edited buffer versions. Different carets/versions must produce fresh checks;
+identical carets must reuse their graph. Completed retained cursor entries must stay within
+four, inside the ordinary eight-entry/64 MiB estimated cache budget.
+
+The rapid-edit case gates the existing `BeforeCheck` test seam, submits 24 distinct captured
+versions, cancels obsolete waiting requests and releases shared work. Runner-only reflection
+under the service's own gate observes admission, active-build slots and retained cursor
+entries. It adds no public protocol method and performs no inference. Results record canceled
+waiters, the blocked admission observation, settlement/slot cleanup and a fresh request's
+latency. Cancellation of a waiter does not imply its shared build was canceled.
+The fresh sample's process-wide check count includes shared builds draining after release;
+it is not the number of checks owned exclusively by the fresh request.
+
+The CLR case alternates actual core/server assembly bytes at a custom reference path and
+requires distinct metadata generations. It reports unchanged-generation costs separately
+from replacement/recheck costs. Capture, content hashing and physical currentness validation
+are included; no restore or executable assembly loading is introduced.
+
+Time and process-wide allocation windows include service acquisition, final validation and
+consumer projection. Serialization/fingerprinting occurs afterward. Service payload sizes
+describe compact benchmark DTOs, not LSP wire messages. Managed heap is observed after full
+GC in an empty session, with active completed caches, and after invalidation with leases and
+query results released. Differences remain process-wide observations affected by runtime
+state; estimated graph bytes, allocation churn and retained heap are distinct quantities.
+Active leases, in-flight work, CLR/runtime state and estimator error are outside the completed
+cache estimate; it is not a strict process-memory bound.
+
+`stdio.mjs` uses the same on-disk fixtures in three fresh full-mode processes each. It reports
+spawn-to-initialize separately from first-sequence cost, then 15 unchanged sequences in one
+process. It uses the standalone `--diagnostics all` preset; asynchronous diagnostics can
+share or contend for analysis. Payload sizes include parsed JSON-RPC response fields and
+exclude Content-Length framing and notifications. These runs observe no internal counters,
+so their latencies are not presented as checker-count measurements.
+Fingerprints preserve semantic fields and normalize fixture URIs plus only OmniSharp's random
+per-process completion routing ID. Payload byte counts retain that routing data and JSON-RPC IDs.
+
+These new features report absolute costs. Existing equivalent-path baseline comparisons
+remain in [editor analysis](../editor-analysis/README.md), [parser syntax](../editor-syntax/README.md),
+[member checking](../member-provenance/README.md) and [semantic metadata](../editor-semantics/README.md).
+Use their exact archived baselines/options/fingerprints for percentage comparisons; no missing
+historical hover/completion/signature implementation is treated as a performance baseline.
+
+## Recorded final measurements
+
+[Compact results](results-windows-arm64.json) were measured on 2026-10-07 local time
+(2026-10-08 UTC), Windows ARM64, .NET 10.0.12 and Node 25.5.0, on an otherwise idle machine.
+The source base was `a20b07d7259043dd06a33fae34a507b443c847d8` plus #1981 working-tree changes.
+The final LS SHA-256 was `9dc0e3c3b4d401e833735179ee84185c460b9f2dcb2ae187f71f8e808627a40b`;
+core SHA-256 was `a2ee2949aace31ab14706d2bf2352125ed94d9fed085cb76ec7b55d95909e45b`.
+Replay source fingerprints and binary fingerprints accompany the measurements. Raw samples
+remain in ignored `artifacts/editor-workflow-benchmark`.
+
+The seven-feature service sequence reports absolute costs, including currentness checks:
+
+| Fixture | Cold median / p90 ms | Warm median / p90 ms | Cold / warm allocated MiB | Cold / warm checks |
+| --- | ---: | ---: | ---: | ---: |
+| Small | 18.37 / 19.84 | 6.73 / 7.41 | 0.93 / 0.25 | 1 / 0 |
+| 25 sources / 4 configs | 341.41 / 485.19 | 27.96 / 29.55 | 70.36 / 6.09 | 3 / 0 |
+| Default libraries | 179.27 / 184.24 | 7.45 / 8.72 | 85.16 / 0.25 | 1 / 0 |
+
+Changed source versions rechecked their components (medians 19.21 / 228.03 / 152.12 ms);
+dirty multi-project dependencies took 210.69 ms and three checks. First recovered completion /
+signature queries took 19.06 / 14.67 ms with two checks; identical carets took 1.28 / 1.17 ms
+with zero checks and identical fingerprints. Changed carets rechecked once, changed versions
+twice. Both cursor runs retained eight total entries, four cursor entries, below 246 KB estimated.
+
+The 24-request cancellation burst observed the configured 16 admitted builds/two active slots,
+settled all canceled waiters, and restored 16 admission slots/two build slots/zero in-flight work.
+It retained eight completed entries. The separate 12-document/24-cursor retention run retained
+seven entries/four cursor entries, estimated 60,516 B. Full-GC active-minus-invalidated heap
+was 168,784 B there. For the multi-project service sequence it was 3,650,696 B, versus an
+8,382,466 B cache estimate. Small/default-library heap deltas were near zero and noisy; they
+do not mean their graphs use no memory. Neither these observations nor the 64 MiB estimated
+completed-cache budget bounds total process memory, live leases or in-flight builds.
+
+Actual stdio spawn-to-initialize medians were 402.14 / 400.84 / 399.42 ms for small / multi /
+default libraries. First seven-request sequences took 443.76 / 768.55 / 894.14 ms after
+initialization; warm sequences took 50.27 / 78.12 / 48.89 ms. Warm response payload totals
+were 1,662–1,670 / 7,122–7,130 / 1,692–1,700 bytes. IDs account for the small payload variation.
+Asynchronous all-mode diagnostics are included in this real process workflow.
+
+## Equivalent baseline and regression investigation
+
+The unchanged pre-existing definition → references → statements/full-diagnostics sequence
+was replayed against exact `df4589b7` archived code and the final DLLs, then repeated in fresh
+processes. The archived checker has only the documented `CheckModules` entry counter;
+current counts use real `Statistics.Checks`. All semantic fingerprints matched.
+
+| Fixture | Baseline → current cold ms | Baseline → current warm ms | Cold allocation change | Cold / warm checks before → after |
+| --- | ---: | ---: | ---: | --- |
+| Small | 20.43 → 15.10 | 15.10 → 3.23 | −30.1% | 4 → 1 / 3 → 0 |
+| Multi-project | 206.00 → 259.38 | 111.73 → 14.30 | +15.3% | 6 → 3 / 5 → 0 |
+
+The multi-project cold regression exceeds 10%: +25.9% (+53.38 ms) in the first pair,
+and +36.9% (+69.05 ms; 186.98 → 256.03 ms) in the fresh-process repeat. Allocation churn
+reproduced at 56.14 → 64.73 MB (+15.3%). Measured consumer phases locate the added cold cost:
+definition 36.24 → 97.93 ms and references 129.50 → 160.19 ms; the repeat showed
+31.89 → 91.63 ms and 119.39 → 153.92 ms. Small cold definition also increased
+5.14 → 6.81 ms (repeat 5.06 → 8.10 ms), despite an overall cold improvement.
+
+Code inspection attributes these cold paths to editor syntax capture, final member/scope/type
+proof capture, immutable publication and disk/config resolution validation. The consumer
+measurements do not isolate each capture/freeze phase, so this is an explanation from code,
+not a measured phase profile. Fewer whole-component checks offset only part of that work.
+The cold tradeoff is retained for the required trustworthy snapshots; this report does not
+claim incremental checking. Warm multi-project time improved 87.2% (85.6% in the repeat),
+with zero checks and about 91.6% lower allocation churn.
+
+Warm diagnostics alone increased from the old unchecked cache hits (0.004 / 0.013 ms)
+to 0.973 / 3.087 ms for small / multi. The repeat was 1.090 / 3.704 ms. The old cache did
+not validate current disk/config dependencies; current output does. Direct currentness
+measurement was 0.314 / 0.977 ms per document validation and 0.426 / 3.006 ms per workspace
+validation. This validation cost is retained rather than publishing stale diagnostics.
+
+Custom CLR references are a separate expensive case: seven unchanged requests with a
+16,900,608 B core DLL reference took a median 370.34 ms with zero checks. Replacing that
+path with alternating 294,912 B LS / core DLL bytes took median 55.00 ms / p90 551.06 ms;
+all seven generations changed, each refreshed sequence rechecked once, and ordinary output
+fingerprints stayed equal. The alternating sizes make this replacement distribution bimodal.
+Repeated physical metadata capture/content hashing/currentness are included, without a
+phase profile attributing all latency to hashing. This is an explicit performance limit and
+a follow-up optimization target for users with large custom references.
+
+To replay the exact historical comparison, use the existing archive/counter driver, then
+run each prepared runner again for an independent process pair:
+
+```powershell
+./benchmarks/editor-analysis/run.ps1 -BaselineCommit df4589b7 -OutputDirectory artifacts/editor-workflow-benchmark/equivalent
+dotnet artifacts/editor-workflow-benchmark/equivalent/bin/baseline/Release/net10.0/SharpTS.Tests.dll artifacts/editor-workflow-benchmark/equivalent baseline-repeat.json df4589b7
+dotnet artifacts/editor-workflow-benchmark/equivalent/bin/current/Release/net10.0/SharpTS.Tests.dll artifacts/editor-workflow-benchmark/equivalent current-repeat.json CURRENT_SOURCE_BASE
+node benchmarks/editor-workflow/summarize.mjs artifacts/editor-workflow-benchmark artifacts/editor-workflow-benchmark/equivalent benchmarks/editor-workflow/results-windows-arm64.json
+```
+
+The comparison driver explicitly builds its archived/current libraries before measuring.
+For this recorded run the archive was already built, so only the two independent friend
+assemblies were rebuilt against its DLLs and the frozen final current DLLs. No production
+build, editor process or other test ran during any reported timing sample. These are
+single-machine observations, not cross-machine latency gates or confidence intervals.
diff --git a/benchmarks/editor-workflow/results-windows-arm64.json b/benchmarks/editor-workflow/results-windows-arm64.json
new file mode 100644
index 000000000..bc5daabb9
--- /dev/null
+++ b/benchmarks/editor-workflow/results-windows-arm64.json
@@ -0,0 +1,1159 @@
+{
+ "schemaVersion": 1,
+ "replaySourceSha256": {
+ "Program.cs": "747d1f3e5b65a6723bedc74ddd36b9d802d114f52fb1ff138bf96090f08ff7e1",
+ "EditorWorkflow.csproj": "5bfa3b90ed99b853624930e04e523de65d13889f589de5de82839b100ce05702",
+ "run.ps1": "9e458880f827d9b64504ffea351dfbf0a63241633a53ee432a4bcd7d0b3ab589",
+ "stdio.mjs": "615fdd7019d8189ab4e7db7285eea94be78f12a07fbfbf148d7702368a5cc1ff",
+ "summarize.mjs": "4cc1bff3b53e17b54b2e8d0fe0cc60809380bc75d4d68da01b0bf35b4582426c"
+ },
+ "workflow": {
+ "timestampUtc": "2026-10-08T05:09:22.3445472Z",
+ "sourceCommit": "a20b07d7259043dd06a33fae34a507b443c847d8",
+ "binaries": {
+ "languageServerSha256": "9dc0e3c3b4d401e833735179ee84185c460b9f2dcb2ae187f71f8e808627a40b",
+ "compilerSha256": "a2ee2949aace31ab14706d2bf2352125ed94d9fed085cb76ec7b55d95909e45b"
+ },
+ "runtime": ".NET 10.0.12",
+ "os": "Microsoft Windows 10.0.26220",
+ "architecture": "Arm64",
+ "coldSamples": 11,
+ "warmSamples": 31,
+ "methodology": "Current-version actual service sequence. Cold means empty service cache in a JIT/OS-cache-warmed process; warm means identical captured overlays and caret. All query currentness checks are included. Allocations are process-wide across async workers. Payload serialization/fingerprinting follows the measured window. Managed heap is a full-GC observation, not a cache/process limit. No prior equivalent for new features; earlier exact-baseline reports remain separate.",
+ "reports": [
+ {
+ "name": "small",
+ "SourceFiles": 1,
+ "Projects": 1,
+ "ExpectedReferences": 2,
+ "freshServiceConstruction": {
+ "medianMilliseconds": 0.0209,
+ "p90Milliseconds": 0.0409,
+ "medianAllocatedBytes": 6656
+ },
+ "cold": {
+ "sampleCount": 11,
+ "medianMilliseconds": 18.3651,
+ "p90Milliseconds": 19.8377,
+ "medianAllocatedBytes": 976272,
+ "checks": [
+ 1
+ ],
+ "builds": [
+ 2
+ ],
+ "fingerprints": [
+ "5300052BA6DE4A4ED29851557F85E8EC4878BD6746621285BAE00B3FC8C5F9EF"
+ ],
+ "payloadBytes": [
+ 989
+ ]
+ },
+ "warm": {
+ "sampleCount": 31,
+ "medianMilliseconds": 6.7336,
+ "p90Milliseconds": 7.4085,
+ "medianAllocatedBytes": 262656,
+ "checks": [
+ 0
+ ],
+ "builds": [
+ 0
+ ],
+ "fingerprints": [
+ "5300052BA6DE4A4ED29851557F85E8EC4878BD6746621285BAE00B3FC8C5F9EF"
+ ],
+ "payloadBytes": [
+ 989
+ ]
+ },
+ "changedOpenVersion": {
+ "sampleCount": 7,
+ "medianMilliseconds": 19.2149,
+ "p90Milliseconds": 19.8481,
+ "medianAllocatedBytes": 974352,
+ "checks": [
+ 1
+ ],
+ "builds": [
+ 2
+ ],
+ "fingerprints": [
+ "5300052BA6DE4A4ED29851557F85E8EC4878BD6746621285BAE00B3FC8C5F9EF"
+ ],
+ "payloadBytes": [
+ 989
+ ]
+ },
+ "changedOpenDependency": null,
+ "RetainedSnapshots": 2,
+ "EstimatedRetainedBytes": 59090,
+ "memory": {
+ "emptySessionManagedHeap": 852648,
+ "activeCacheManagedHeap": 851192,
+ "afterInvalidationManagedHeap": 850912,
+ "activeMinusEmptyBytes": -1456,
+ "activeMinusInvalidatedBytes": 280
+ },
+ "sourceDocumentsInEntrySnapshot": 1
+ },
+ {
+ "name": "multi-project",
+ "SourceFiles": 25,
+ "Projects": 4,
+ "ExpectedReferences": 25,
+ "freshServiceConstruction": {
+ "medianMilliseconds": 0.0621,
+ "p90Milliseconds": 0.0773,
+ "medianAllocatedBytes": 6904
+ },
+ "cold": {
+ "sampleCount": 11,
+ "medianMilliseconds": 341.4121,
+ "p90Milliseconds": 485.1922,
+ "medianAllocatedBytes": 73776616,
+ "checks": [
+ 3
+ ],
+ "builds": [
+ 3
+ ],
+ "fingerprints": [
+ "40113F7B3E0B6CF94BCF8FF2F27A3C822B9137DCBABF6B27FD0055ACB1C878C3"
+ ],
+ "payloadBytes": [
+ 3642
+ ]
+ },
+ "warm": {
+ "sampleCount": 31,
+ "medianMilliseconds": 27.9571,
+ "p90Milliseconds": 29.5466,
+ "medianAllocatedBytes": 6387800,
+ "checks": [
+ 0
+ ],
+ "builds": [
+ 0
+ ],
+ "fingerprints": [
+ "40113F7B3E0B6CF94BCF8FF2F27A3C822B9137DCBABF6B27FD0055ACB1C878C3"
+ ],
+ "payloadBytes": [
+ 3642
+ ]
+ },
+ "changedOpenVersion": {
+ "sampleCount": 7,
+ "medianMilliseconds": 228.0271,
+ "p90Milliseconds": 233.9758,
+ "medianAllocatedBytes": 67363512,
+ "checks": [
+ 3
+ ],
+ "builds": [
+ 3
+ ],
+ "fingerprints": [
+ "40113F7B3E0B6CF94BCF8FF2F27A3C822B9137DCBABF6B27FD0055ACB1C878C3"
+ ],
+ "payloadBytes": [
+ 3642
+ ]
+ },
+ "changedOpenDependency": {
+ "sampleCount": 7,
+ "medianMilliseconds": 210.6916,
+ "p90Milliseconds": 218.7471,
+ "medianAllocatedBytes": 66307280,
+ "checks": [
+ 3
+ ],
+ "builds": [
+ 3
+ ],
+ "fingerprints": [
+ "40113F7B3E0B6CF94BCF8FF2F27A3C822B9137DCBABF6B27FD0055ACB1C878C3"
+ ],
+ "payloadBytes": [
+ 3642
+ ]
+ },
+ "RetainedSnapshots": 3,
+ "EstimatedRetainedBytes": 8382466,
+ "memory": {
+ "emptySessionManagedHeap": 4977624,
+ "activeCacheManagedHeap": 8616760,
+ "afterInvalidationManagedHeap": 4966064,
+ "activeMinusEmptyBytes": 3639136,
+ "activeMinusInvalidatedBytes": 3650696
+ },
+ "sourceDocumentsInEntrySnapshot": 13
+ },
+ {
+ "name": "default-library",
+ "SourceFiles": 1,
+ "Projects": 1,
+ "ExpectedReferences": 2,
+ "freshServiceConstruction": {
+ "medianMilliseconds": 0.0318,
+ "p90Milliseconds": 0.0321,
+ "medianAllocatedBytes": 6840
+ },
+ "cold": {
+ "sampleCount": 3,
+ "medianMilliseconds": 179.2652,
+ "p90Milliseconds": 184.2372,
+ "medianAllocatedBytes": 89300264,
+ "checks": [
+ 1
+ ],
+ "builds": [
+ 2
+ ],
+ "fingerprints": [
+ "5300052BA6DE4A4ED29851557F85E8EC4878BD6746621285BAE00B3FC8C5F9EF"
+ ],
+ "payloadBytes": [
+ 989
+ ]
+ },
+ "warm": {
+ "sampleCount": 7,
+ "medianMilliseconds": 7.4453,
+ "p90Milliseconds": 8.7189,
+ "medianAllocatedBytes": 261984,
+ "checks": [
+ 0
+ ],
+ "builds": [
+ 0
+ ],
+ "fingerprints": [
+ "5300052BA6DE4A4ED29851557F85E8EC4878BD6746621285BAE00B3FC8C5F9EF"
+ ],
+ "payloadBytes": [
+ 989
+ ]
+ },
+ "changedOpenVersion": {
+ "sampleCount": 7,
+ "medianMilliseconds": 152.124,
+ "p90Milliseconds": 190.855,
+ "medianAllocatedBytes": 89196616,
+ "checks": [
+ 1
+ ],
+ "builds": [
+ 2
+ ],
+ "fingerprints": [
+ "5300052BA6DE4A4ED29851557F85E8EC4878BD6746621285BAE00B3FC8C5F9EF"
+ ],
+ "payloadBytes": [
+ 989
+ ]
+ },
+ "changedOpenDependency": null,
+ "RetainedSnapshots": 2,
+ "EstimatedRetainedBytes": 1475118,
+ "memory": {
+ "emptySessionManagedHeap": 16977040,
+ "activeCacheManagedHeap": 16973744,
+ "afterInvalidationManagedHeap": 16973400,
+ "activeMinusEmptyBytes": -3296,
+ "activeMinusInvalidatedBytes": 344
+ },
+ "sourceDocumentsInEntrySnapshot": 1
+ }
+ ],
+ "recovery": [
+ {
+ "kind": "member-completion",
+ "first": {
+ "Milliseconds": 19.0628,
+ "AllocatedBytes": 1161272,
+ "Checks": 2,
+ "Builds": 2,
+ "Fingerprint": "0C82B96D041C04CBF0BAF60FAC7CD8B1FB3F897C95023AFEFBDCC6748C38F550",
+ "PayloadBytes": 163
+ },
+ "unchangedCaret": {
+ "sampleCount": 15,
+ "medianMilliseconds": 1.277,
+ "p90Milliseconds": 1.6645,
+ "medianAllocatedBytes": 46184,
+ "checks": [
+ 0
+ ],
+ "builds": [
+ 0
+ ],
+ "fingerprints": [
+ "0C82B96D041C04CBF0BAF60FAC7CD8B1FB3F897C95023AFEFBDCC6748C38F550"
+ ],
+ "payloadBytes": [
+ 163
+ ]
+ },
+ "changedCaret": {
+ "sampleCount": 8,
+ "medianMilliseconds": 9.572,
+ "p90Milliseconds": 25.9347,
+ "medianAllocatedBytes": 615608,
+ "checks": [
+ 1
+ ],
+ "builds": [
+ 1
+ ],
+ "fingerprints": [
+ "FE9F4B5878071DE5DD7CEC39709ED47C3D8BD4426130A26F2D74EDDB862B8B31",
+ "E34FC9F46C496AB136B97BFB25E0E611BE62C251E33B97D6711097E7E1427057",
+ "45A46E3A5BCC77581B43C2D3AF5D2C97F900BC39B0C875B4B807CC4D8C85811A",
+ "713EB2BF57725ED8A62246FFB210FBB575AB9EA099D6E0BE5205A028F3AEE167",
+ "FDB80E994FEA5B4061A74693C845DB9B65B7A30A2439274C1ED3453541852E87",
+ "B58A9F5E68BCA3E42FDD2B9F893D48FBBB70E688C91C93FA2407E2F4C813B1AD",
+ "1E76AB8D9C10D72514851FE3E5AC595971432F8777D0B1BB23A7D0ED05536091",
+ "6E97037F219E30A025386B964BCED7FA08C07C87E348EE8CDCF9F997FBBDA5D9"
+ ],
+ "payloadBytes": [
+ 163,
+ 165
+ ]
+ },
+ "changedVersion": {
+ "sampleCount": 7,
+ "medianMilliseconds": 12.849,
+ "p90Milliseconds": 17.5586,
+ "medianAllocatedBytes": 1153904,
+ "checks": [
+ 2
+ ],
+ "builds": [
+ 2
+ ],
+ "fingerprints": [
+ "B63F78B0DA074660734CF53B531C647E8C26F0F68E8FE42A4168F07A90A8CE63"
+ ],
+ "payloadBytes": [
+ 165
+ ]
+ },
+ "cache": {
+ "Inflight": 0,
+ "AdmissionAvailable": 16,
+ "BuildSlotsAvailable": 2,
+ "RetainedEntries": 8,
+ "CursorEntries": 4,
+ "EstimatedBytes": 241616
+ }
+ },
+ {
+ "kind": "signature-help",
+ "first": {
+ "Milliseconds": 14.6721,
+ "AllocatedBytes": 1161032,
+ "Checks": 2,
+ "Builds": 2,
+ "Fingerprint": "43B1FED31071C58624102E9C0BB844F0BEA97A2DE73A2738A428F9AD43A3CC20",
+ "PayloadBytes": 129
+ },
+ "unchangedCaret": {
+ "sampleCount": 15,
+ "medianMilliseconds": 1.1715,
+ "p90Milliseconds": 1.2635,
+ "medianAllocatedBytes": 48480,
+ "checks": [
+ 0
+ ],
+ "builds": [
+ 0
+ ],
+ "fingerprints": [
+ "43B1FED31071C58624102E9C0BB844F0BEA97A2DE73A2738A428F9AD43A3CC20"
+ ],
+ "payloadBytes": [
+ 129
+ ]
+ },
+ "changedCaret": {
+ "sampleCount": 8,
+ "medianMilliseconds": 6.6962,
+ "p90Milliseconds": 9.0013,
+ "medianAllocatedBytes": 619224,
+ "checks": [
+ 1
+ ],
+ "builds": [
+ 1
+ ],
+ "fingerprints": [
+ "43B1FED31071C58624102E9C0BB844F0BEA97A2DE73A2738A428F9AD43A3CC20"
+ ],
+ "payloadBytes": [
+ 129
+ ]
+ },
+ "changedVersion": {
+ "sampleCount": 7,
+ "medianMilliseconds": 11.8333,
+ "p90Milliseconds": 13.1078,
+ "medianAllocatedBytes": 1162032,
+ "checks": [
+ 2
+ ],
+ "builds": [
+ 2
+ ],
+ "fingerprints": [
+ "43B1FED31071C58624102E9C0BB844F0BEA97A2DE73A2738A428F9AD43A3CC20"
+ ],
+ "payloadBytes": [
+ 129
+ ]
+ },
+ "cache": {
+ "Inflight": 0,
+ "AdmissionAvailable": 16,
+ "BuildSlotsAvailable": 2,
+ "RetainedEntries": 8,
+ "CursorEntries": 4,
+ "EstimatedBytes": 245080
+ }
+ }
+ ],
+ "burst": {
+ "requests": 24,
+ "canceledWaiters": 24,
+ "blocked": {
+ "Inflight": 16,
+ "AdmissionAvailable": 0,
+ "BuildSlotsAvailable": 0,
+ "RetainedEntries": 0,
+ "CursorEntries": 0,
+ "EstimatedBytes": 0
+ },
+ "cancellationSettlementMilliseconds": 0.4425,
+ "checkedBuildsEnteringGate": 2,
+ "fresh": {
+ "Milliseconds": 75.6457,
+ "AllocatedBytes": 8940488,
+ "Checks": 16,
+ "Builds": 16,
+ "Fingerprint": "5300052BA6DE4A4ED29851557F85E8EC4878BD6746621285BAE00B3FC8C5F9EF",
+ "PayloadBytes": 989
+ },
+ "completed": {
+ "Inflight": 0,
+ "AdmissionAvailable": 16,
+ "BuildSlotsAvailable": 2,
+ "RetainedEntries": 8,
+ "CursorEntries": 0,
+ "EstimatedBytes": 235836
+ },
+ "statistics": {
+ "Builds": 18,
+ "Checks": 17,
+ "CacheHits": 7,
+ "RetainedSnapshots": 8,
+ "EstimatedRetainedBytes": 235836
+ }
+ },
+ "metadata": {
+ "referenceBytes": 294912,
+ "originalGeneration": 0,
+ "generations": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6,
+ 7
+ ],
+ "unchangedGeneration": {
+ "sampleCount": 7,
+ "medianMilliseconds": 370.3403,
+ "p90Milliseconds": 396.4943,
+ "medianAllocatedBytes": 554200,
+ "checks": [
+ 0
+ ],
+ "builds": [
+ 0
+ ],
+ "fingerprints": [
+ "5300052BA6DE4A4ED29851557F85E8EC4878BD6746621285BAE00B3FC8C5F9EF"
+ ],
+ "payloadBytes": [
+ 989
+ ]
+ },
+ "replacedAssembly": {
+ "sampleCount": 7,
+ "medianMilliseconds": 55.0024,
+ "p90Milliseconds": 551.0646,
+ "medianAllocatedBytes": 2806720,
+ "checks": [
+ 1
+ ],
+ "builds": [
+ 2
+ ],
+ "fingerprints": [
+ "5300052BA6DE4A4ED29851557F85E8EC4878BD6746621285BAE00B3FC8C5F9EF"
+ ],
+ "payloadBytes": [
+ 989
+ ]
+ },
+ "note": "Real custom CLR reference bytes alternate between core and LS assemblies; BCL remains available. Full metadata capture/hash/currentness cost is included, no restore or executable loading.",
+ "referenceAssemblyBytes": {
+ "core": 16900608,
+ "languageServer": 294912
+ }
+ },
+ "retention": {
+ "documents": 12,
+ "cursorRequests": 24,
+ "cached": {
+ "Inflight": 0,
+ "AdmissionAvailable": 16,
+ "BuildSlotsAvailable": 2,
+ "RetainedEntries": 7,
+ "CursorEntries": 4,
+ "EstimatedBytes": 60516
+ },
+ "emptyManagedHeap": 24392328,
+ "activeManagedHeap": 24570096,
+ "invalidatedManagedHeap": 24401312,
+ "releasedManagedHeapObservation": 168784
+ }
+ },
+ "stdio": {
+ "timestampUtc": "2026-10-08T05:10:56.501Z",
+ "node": "v25.5.0",
+ "platform": "win32-arm64",
+ "server": "SharpTS.LanguageServer.dll",
+ "serverSha256": "9dc0e3c3b4d401e833735179ee84185c460b9f2dcb2ae187f71f8e808627a40b",
+ "methodology": "Actual full-mode stdio using the same on-disk service fixtures. Three fresh processes per fixture; initialize includes spawn/startup separately from the first request sequence. Fifteen unchanged sequences in the first process. Byte counts are parsed JSON response bytes (including JSON-RPC fields, excluding Content-Length framing), not raw pipe framing or notifications. Fingerprints normalize fixture URIs and only OmniSharp's random per-process completion routing ID; semantic fields remain intact. No internal server counters are observed in this run. Diagnostic publication uses the standalone all preset and may share/contend for analysis, as in a real client.",
+ "reports": [
+ {
+ "name": "small",
+ "freshProcessInitialize": {
+ "count": 3,
+ "medianMilliseconds": 402.1426999999999,
+ "p90Milliseconds": 404.4795,
+ "payloadBytes": [
+ 686
+ ],
+ "fingerprints": []
+ },
+ "firstSequenceAfterInitialize": {
+ "count": 3,
+ "medianMilliseconds": 443.75879999999995,
+ "p90Milliseconds": 452.1582000000003,
+ "payloadBytes": [
+ 1656
+ ],
+ "fingerprints": [
+ "e32e7592d268165d2039ec8853d730af1aaa8ab9d85bed15fdc35cf3423c5309"
+ ]
+ },
+ "unchangedWarmSequence": {
+ "count": 15,
+ "medianMilliseconds": 50.26819999999998,
+ "p90Milliseconds": 55.620900000000006,
+ "payloadBytes": [
+ 1662,
+ 1663,
+ 1670
+ ],
+ "fingerprints": [
+ "e32e7592d268165d2039ec8853d730af1aaa8ab9d85bed15fdc35cf3423c5309"
+ ]
+ }
+ },
+ {
+ "name": "multi-project",
+ "freshProcessInitialize": {
+ "count": 3,
+ "medianMilliseconds": 400.84369999999944,
+ "p90Milliseconds": 401.9751000000001,
+ "payloadBytes": [
+ 686
+ ],
+ "fingerprints": []
+ },
+ "firstSequenceAfterInitialize": {
+ "count": 3,
+ "medianMilliseconds": 768.5542999999998,
+ "p90Milliseconds": 781.3116999999993,
+ "payloadBytes": [
+ 7116
+ ],
+ "fingerprints": [
+ "4839453e6a4f413edbdc53adb7b88c83878ed22fc8e371dde3550b838e7327f1"
+ ]
+ },
+ "unchangedWarmSequence": {
+ "count": 15,
+ "medianMilliseconds": 78.1204000000007,
+ "p90Milliseconds": 86.64179999999942,
+ "payloadBytes": [
+ 7122,
+ 7123,
+ 7130
+ ],
+ "fingerprints": [
+ "4839453e6a4f413edbdc53adb7b88c83878ed22fc8e371dde3550b838e7327f1"
+ ]
+ }
+ },
+ {
+ "name": "default-library",
+ "freshProcessInitialize": {
+ "count": 3,
+ "medianMilliseconds": 399.41820000000007,
+ "p90Milliseconds": 400.27030000000013,
+ "payloadBytes": [
+ 686
+ ],
+ "fingerprints": []
+ },
+ "firstSequenceAfterInitialize": {
+ "count": 3,
+ "medianMilliseconds": 894.1370999999999,
+ "p90Milliseconds": 896.965400000001,
+ "payloadBytes": [
+ 1686
+ ],
+ "fingerprints": [
+ "e32e7592d268165d2039ec8853d730af1aaa8ab9d85bed15fdc35cf3423c5309"
+ ]
+ },
+ "unchangedWarmSequence": {
+ "count": 15,
+ "medianMilliseconds": 48.89090000000033,
+ "p90Milliseconds": 55.66909999999916,
+ "payloadBytes": [
+ 1692,
+ 1693,
+ 1700
+ ],
+ "fingerprints": [
+ "e32e7592d268165d2039ec8853d730af1aaa8ab9d85bed15fdc35cf3423c5309"
+ ]
+ }
+ }
+ ]
+ },
+ "equivalentAnalysis": {
+ "baselineCommit": "df4589b7",
+ "baselineTimestampUtc": "2026-10-08T05:07:02.2586639Z",
+ "currentTimestampUtc": "2026-10-08T05:08:01.5101366Z",
+ "environment": {
+ "runtime": ".NET 10.0.12",
+ "os": "Microsoft Windows 10.0.26220",
+ "architecture": "Arm64",
+ "processorCount": 12
+ },
+ "binaries": {
+ "baseline": {
+ "languageServerSha256": "ed5a808e04b4217e59374038845b2c38ef4018cce9a84ee85ad02cfb47de19ed",
+ "compilerSha256": "db08b43d47c23232eea0d79ca00b5ea50d8d416365e2cec774a35f5055aae4c8"
+ },
+ "current": {
+ "languageServerSha256": "9dc0e3c3b4d401e833735179ee84185c460b9f2dcb2ae187f71f8e808627a40b",
+ "compilerSha256": "a2ee2949aace31ab14706d2bf2352125ed94d9fed085cb76ec7b55d95909e45b"
+ }
+ },
+ "methodology": "Same definition, references(includeDeclaration=true, workspace roots), statements, full diagnostics sequence. Fresh service cache for each cold sample; repeated unchanged capture for warm samples. Process/JIT/filesystem caches warmed. Deterministic noLib/types=[] fixtures isolate source graph. Services only, no transport or CLR metadata provider. Allocation measured process-wide across async workers. Baseline CheckModules entry counter is the only archived production source change; current uses Statistics.Checks. Retained heap is noisy process-wide full-GC live heap, cache bytes are implementation estimates.",
+ "reports": [
+ {
+ "name": "small",
+ "cold": {
+ "baseline": {
+ "medianMilliseconds": 20.4304,
+ "p90Milliseconds": 20.9224,
+ "medianAllocatedBytes": 774056,
+ "checksPerSequence": [
+ 4
+ ],
+ "resultFingerprint": "0D452F0C8902E0D5EEF6E72D1C67A1F9DDA7A27A35D06F31916F4E9D73EE3DBB",
+ "featureMedianMilliseconds": [
+ 5.1432,
+ 9.9994,
+ 5.1393
+ ],
+ "featureMedianAllocatedBytes": [
+ 194088,
+ 382112,
+ 193728
+ ]
+ },
+ "current": {
+ "medianMilliseconds": 15.1038,
+ "p90Milliseconds": 16.0725,
+ "medianAllocatedBytes": 541296,
+ "checksPerSequence": [
+ 1
+ ],
+ "resultFingerprint": "0D452F0C8902E0D5EEF6E72D1C67A1F9DDA7A27A35D06F31916F4E9D73EE3DBB",
+ "featureMedianMilliseconds": [
+ 6.8093,
+ 7.2136,
+ 0.9868
+ ],
+ "featureMedianAllocatedBytes": [
+ 293896,
+ 209984,
+ 33320
+ ]
+ },
+ "medianChangePercent": -26.071932022867884,
+ "allocatedChangePercent": -30.070175801234022
+ },
+ "warm": {
+ "baseline": {
+ "medianMilliseconds": 15.0986,
+ "p90Milliseconds": 15.2941,
+ "medianAllocatedBytes": 571744,
+ "checksPerSequence": [
+ 3
+ ],
+ "resultFingerprint": "0D452F0C8902E0D5EEF6E72D1C67A1F9DDA7A27A35D06F31916F4E9D73EE3DBB",
+ "featureMedianMilliseconds": [
+ 5.1248,
+ 9.8912,
+ 0.0037
+ ],
+ "featureMedianAllocatedBytes": [
+ 184640,
+ 382144,
+ 792
+ ]
+ },
+ "current": {
+ "medianMilliseconds": 3.2258,
+ "p90Milliseconds": 3.3171,
+ "medianAllocatedBytes": 119808,
+ "checksPerSequence": [
+ 0
+ ],
+ "resultFingerprint": "0D452F0C8902E0D5EEF6E72D1C67A1F9DDA7A27A35D06F31916F4E9D73EE3DBB",
+ "featureMedianMilliseconds": [
+ 0.6503,
+ 1.5597,
+ 0.9732
+ ],
+ "featureMedianAllocatedBytes": [
+ 23272,
+ 59048,
+ 33320
+ ]
+ },
+ "medianChangePercent": -78.63510524154557,
+ "allocatedChangePercent": -79.04516706777859
+ },
+ "retention": {
+ "baseline": {
+ "LiveHeapWithCacheBytes": 496760,
+ "LiveHeapAfterCacheClearBytes": 494344,
+ "CacheRetainedHeapBytes": 2416,
+ "EstimatedCachedBytes": null,
+ "CachedSnapshots": null
+ },
+ "current": {
+ "LiveHeapWithCacheBytes": 581872,
+ "LiveHeapAfterCacheClearBytes": 557664,
+ "CacheRetainedHeapBytes": 24208,
+ "EstimatedCachedBytes": 9928,
+ "CachedSnapshots": 2
+ }
+ },
+ "validation": {
+ "document": {
+ "inputs": 12,
+ "observedInputEstimatedBytes": 482,
+ "medianMilliseconds": 0.3136,
+ "p90Milliseconds": 0.3238,
+ "medianAllocatedBytes": 8680
+ },
+ "workspace": {
+ "inputs": 15,
+ "observedInputEstimatedBytes": 774,
+ "medianMilliseconds": 0.4262,
+ "p90Milliseconds": 0.44,
+ "medianAllocatedBytes": 10144
+ }
+ }
+ },
+ {
+ "name": "multi-project",
+ "cold": {
+ "baseline": {
+ "medianMilliseconds": 205.9992,
+ "p90Milliseconds": 276.106,
+ "medianAllocatedBytes": 56143688,
+ "checksPerSequence": [
+ 6
+ ],
+ "resultFingerprint": "215F3E9CFCFAEDE2C9783683F26A6766859D588F43DE8AF99EDA41422A47ACE0",
+ "featureMedianMilliseconds": [
+ 36.2393,
+ 129.5037,
+ 38.2916
+ ],
+ "featureMedianAllocatedBytes": [
+ 10654616,
+ 34398616,
+ 10977096
+ ]
+ },
+ "current": {
+ "medianMilliseconds": 259.3778,
+ "p90Milliseconds": 268.0345,
+ "medianAllocatedBytes": 64736336,
+ "checksPerSequence": [
+ 3
+ ],
+ "resultFingerprint": "215F3E9CFCFAEDE2C9783683F26A6766859D588F43DE8AF99EDA41422A47ACE0",
+ "featureMedianMilliseconds": [
+ 97.93,
+ 160.19,
+ 3.2803
+ ],
+ "featureMedianAllocatedBytes": [
+ 28184080,
+ 35667480,
+ 777032
+ ]
+ },
+ "medianChangePercent": 25.912042376863596,
+ "allocatedChangePercent": 15.304744497725187
+ },
+ "warm": {
+ "baseline": {
+ "medianMilliseconds": 111.7312,
+ "p90Milliseconds": 125.7972,
+ "medianAllocatedBytes": 40256928,
+ "checksPerSequence": [
+ 5
+ ],
+ "resultFingerprint": "215F3E9CFCFAEDE2C9783683F26A6766859D588F43DE8AF99EDA41422A47ACE0",
+ "featureMedianMilliseconds": [
+ 22.7955,
+ 87.5965,
+ 0.013
+ ],
+ "featureMedianAllocatedBytes": [
+ 9488976,
+ 30648200,
+ 6392
+ ]
+ },
+ "current": {
+ "medianMilliseconds": 14.3041,
+ "p90Milliseconds": 15.6062,
+ "medianAllocatedBytes": 3368120,
+ "checksPerSequence": [
+ 0
+ ],
+ "resultFingerprint": "215F3E9CFCFAEDE2C9783683F26A6766859D588F43DE8AF99EDA41422A47ACE0",
+ "featureMedianMilliseconds": [
+ 2.0304,
+ 8.89,
+ 3.0868
+ ],
+ "featureMedianAllocatedBytes": [
+ 514056,
+ 1963672,
+ 777032
+ ]
+ },
+ "medianChangePercent": -87.19775675907893,
+ "allocatedChangePercent": -91.63344008762914
+ },
+ "retention": {
+ "baseline": {
+ "LiveHeapWithCacheBytes": 902160,
+ "LiveHeapAfterCacheClearBytes": 820216,
+ "CacheRetainedHeapBytes": 81944,
+ "EstimatedCachedBytes": null,
+ "CachedSnapshots": null
+ },
+ "current": {
+ "LiveHeapWithCacheBytes": 16733800,
+ "LiveHeapAfterCacheClearBytes": 8963880,
+ "CacheRetainedHeapBytes": 7769920,
+ "EstimatedCachedBytes": 6775212,
+ "CachedSnapshots": 2
+ }
+ },
+ "validation": {
+ "document": {
+ "inputs": 33,
+ "observedInputEstimatedBytes": 56726,
+ "medianMilliseconds": 0.9771,
+ "p90Milliseconds": 1.1526,
+ "medianAllocatedBytes": 252800
+ },
+ "workspace": {
+ "inputs": 91,
+ "observedInputEstimatedBytes": 122322,
+ "medianMilliseconds": 3.0061,
+ "p90Milliseconds": 3.4346,
+ "medianAllocatedBytes": 551000
+ }
+ }
+ }
+ ],
+ "freshProcessRepeat": {
+ "baselineTimestampUtc": "2026-10-08T05:08:23.4666159Z",
+ "currentTimestampUtc": "2026-10-08T05:08:33.1154709Z",
+ "reports": [
+ {
+ "name": "small",
+ "cold": {
+ "baseline": {
+ "medianMilliseconds": 19.8311,
+ "p90Milliseconds": 20.2605,
+ "medianAllocatedBytes": 774096,
+ "checksPerSequence": [
+ 4
+ ],
+ "resultFingerprint": "0D452F0C8902E0D5EEF6E72D1C67A1F9DDA7A27A35D06F31916F4E9D73EE3DBB",
+ "featureMedianMilliseconds": [
+ 5.0643,
+ 9.6948,
+ 5.0052
+ ],
+ "featureMedianAllocatedBytes": [
+ 194088,
+ 382112,
+ 193728
+ ]
+ },
+ "current": {
+ "medianMilliseconds": 17.3767,
+ "p90Milliseconds": 20.1785,
+ "medianAllocatedBytes": 541088,
+ "checksPerSequence": [
+ 1
+ ],
+ "resultFingerprint": "0D452F0C8902E0D5EEF6E72D1C67A1F9DDA7A27A35D06F31916F4E9D73EE3DBB",
+ "featureMedianMilliseconds": [
+ 8.0984,
+ 7.8966,
+ 1.1296
+ ],
+ "featureMedianAllocatedBytes": [
+ 293624,
+ 209984,
+ 33320
+ ]
+ },
+ "medianChangePercent": -12.376519708942013,
+ "allocatedChangePercent": -30.10065934974473
+ },
+ "warm": {
+ "baseline": {
+ "medianMilliseconds": 15.0843,
+ "p90Milliseconds": 15.5312,
+ "medianAllocatedBytes": 571744,
+ "checksPerSequence": [
+ 3
+ ],
+ "resultFingerprint": "0D452F0C8902E0D5EEF6E72D1C67A1F9DDA7A27A35D06F31916F4E9D73EE3DBB",
+ "featureMedianMilliseconds": [
+ 5.1659,
+ 9.899,
+ 0.0035
+ ],
+ "featureMedianAllocatedBytes": [
+ 184640,
+ 382144,
+ 792
+ ]
+ },
+ "current": {
+ "medianMilliseconds": 3.7793,
+ "p90Milliseconds": 4.625,
+ "medianAllocatedBytes": 119808,
+ "checksPerSequence": [
+ 0
+ ],
+ "resultFingerprint": "0D452F0C8902E0D5EEF6E72D1C67A1F9DDA7A27A35D06F31916F4E9D73EE3DBB",
+ "featureMedianMilliseconds": [
+ 0.7487,
+ 1.8454,
+ 1.0901
+ ],
+ "featureMedianAllocatedBytes": [
+ 23272,
+ 59048,
+ 33320
+ ]
+ },
+ "medianChangePercent": -74.94547310780082,
+ "allocatedChangePercent": -79.04516706777859
+ },
+ "retention": {
+ "baseline": {
+ "LiveHeapWithCacheBytes": 496648,
+ "LiveHeapAfterCacheClearBytes": 494232,
+ "CacheRetainedHeapBytes": 2416,
+ "EstimatedCachedBytes": null,
+ "CachedSnapshots": null
+ },
+ "current": {
+ "LiveHeapWithCacheBytes": 582736,
+ "LiveHeapAfterCacheClearBytes": 558240,
+ "CacheRetainedHeapBytes": 24496,
+ "EstimatedCachedBytes": 9928,
+ "CachedSnapshots": 2
+ }
+ },
+ "validation": {
+ "document": {
+ "inputs": 12,
+ "observedInputEstimatedBytes": 482,
+ "medianMilliseconds": 0.3472,
+ "p90Milliseconds": 0.4389,
+ "medianAllocatedBytes": 8680
+ },
+ "workspace": {
+ "inputs": 15,
+ "observedInputEstimatedBytes": 774,
+ "medianMilliseconds": 0.5072,
+ "p90Milliseconds": 0.7516,
+ "medianAllocatedBytes": 10144
+ }
+ }
+ },
+ {
+ "name": "multi-project",
+ "cold": {
+ "baseline": {
+ "medianMilliseconds": 186.9769,
+ "p90Milliseconds": 198.4855,
+ "medianAllocatedBytes": 56143688,
+ "checksPerSequence": [
+ 6
+ ],
+ "resultFingerprint": "215F3E9CFCFAEDE2C9783683F26A6766859D588F43DE8AF99EDA41422A47ACE0",
+ "featureMedianMilliseconds": [
+ 31.8903,
+ 119.3871,
+ 35.3542
+ ],
+ "featureMedianAllocatedBytes": [
+ 10654616,
+ 34398616,
+ 10977096
+ ]
+ },
+ "current": {
+ "medianMilliseconds": 256.0315,
+ "p90Milliseconds": 259.3635,
+ "medianAllocatedBytes": 64727272,
+ "checksPerSequence": [
+ 3
+ ],
+ "resultFingerprint": "215F3E9CFCFAEDE2C9783683F26A6766859D588F43DE8AF99EDA41422A47ACE0",
+ "featureMedianMilliseconds": [
+ 91.6325,
+ 153.9189,
+ 3.2777
+ ],
+ "featureMedianAllocatedBytes": [
+ 28191056,
+ 35655464,
+ 777080
+ ]
+ },
+ "medianChangePercent": 36.93215579036768,
+ "allocatedChangePercent": 15.288600207382164
+ },
+ "warm": {
+ "baseline": {
+ "medianMilliseconds": 118.4045,
+ "p90Milliseconds": 167.3787,
+ "medianAllocatedBytes": 41096408,
+ "checksPerSequence": [
+ 5
+ ],
+ "resultFingerprint": "215F3E9CFCFAEDE2C9783683F26A6766859D588F43DE8AF99EDA41422A47ACE0",
+ "featureMedianMilliseconds": [
+ 25.2589,
+ 91.9264,
+ 0.0124
+ ],
+ "featureMedianAllocatedBytes": [
+ 9675464,
+ 31302856,
+ 6392
+ ]
+ },
+ "current": {
+ "medianMilliseconds": 17.1,
+ "p90Milliseconds": 18.9637,
+ "medianAllocatedBytes": 3368240,
+ "checksPerSequence": [
+ 0
+ ],
+ "resultFingerprint": "215F3E9CFCFAEDE2C9783683F26A6766859D588F43DE8AF99EDA41422A47ACE0",
+ "featureMedianMilliseconds": [
+ 2.3084,
+ 10.773,
+ 3.7039
+ ],
+ "featureMedianAllocatedBytes": [
+ 514080,
+ 1963720,
+ 777080
+ ]
+ },
+ "medianChangePercent": -85.55798132672322,
+ "allocatedChangePercent": -91.80405255855938
+ },
+ "retention": {
+ "baseline": {
+ "LiveHeapWithCacheBytes": 893832,
+ "LiveHeapAfterCacheClearBytes": 811888,
+ "CacheRetainedHeapBytes": 81944,
+ "EstimatedCachedBytes": null,
+ "CachedSnapshots": null
+ },
+ "current": {
+ "LiveHeapWithCacheBytes": 16720048,
+ "LiveHeapAfterCacheClearBytes": 8924640,
+ "CacheRetainedHeapBytes": 7795408,
+ "EstimatedCachedBytes": 6775212,
+ "CachedSnapshots": 2
+ }
+ },
+ "validation": {
+ "document": {
+ "inputs": 33,
+ "observedInputEstimatedBytes": 56726,
+ "medianMilliseconds": 0.985,
+ "p90Milliseconds": 1.0715,
+ "medianAllocatedBytes": 252800
+ },
+ "workspace": {
+ "inputs": 91,
+ "observedInputEstimatedBytes": 122322,
+ "medianMilliseconds": 3.0667,
+ "p90Milliseconds": 3.4941,
+ "medianAllocatedBytes": 551000
+ }
+ }
+ }
+ ]
+ }
+ },
+ "limits": "Single-machine measurements. New feature sequences report absolute costs. Only the unchanged pre-existing definition/reference/statements/diagnostics sequence has an exact historical baseline. Allocation churn, estimated completed-cache bytes and full-GC live heap are distinct. Stdio counters are unobserved. Raw samples remain in ignored artifacts."
+}
diff --git a/benchmarks/editor-workflow/run.ps1 b/benchmarks/editor-workflow/run.ps1
new file mode 100644
index 000000000..44d5e2c46
--- /dev/null
+++ b/benchmarks/editor-workflow/run.ps1
@@ -0,0 +1,75 @@
+param([string]$OutputDirectory = '', [string]$LibraryDirectory = '', [string]$PackageCache = '',
+ [switch]$BuildOnly, [switch]$NoBuild, [switch]$NoStdio)
+$ErrorActionPreference = 'Stop'
+$taskRepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
+if (!$OutputDirectory) { $OutputDirectory = Join-Path $taskRepoRoot 'artifacts/editor-workflow-benchmark' }
+if (!$LibraryDirectory) { $LibraryDirectory = Join-Path $taskRepoRoot 'src/SharpTS.LanguageServer/bin/Release/net10.0' }
+$taskOutputRoot = [IO.Path]::GetFullPath($OutputDirectory)
+$taskLibraryRoot = [IO.Path]::GetFullPath($LibraryDirectory)
+New-Item -ItemType Directory -Path $taskOutputRoot -Force | Out-Null
+if (!(Test-Path -LiteralPath (Join-Path $taskLibraryRoot 'SharpTS.LanguageServer.dll'))) { throw 'Build the production Release language server first.' }
+$taskRestoreOptions = @('-p:NuGetAudit=false', '-p:MinVerVersionOverride=0.0.0-benchmark')
+function Invoke-WorkflowProcess([string]$Executable, [string[]]$Arguments, [int]$TimeoutSeconds, [string]$Name) {
+ $start = [Diagnostics.ProcessStartInfo]::new()
+ $start.FileName = $Executable
+ $start.UseShellExecute = $false
+ $start.CreateNoWindow = $true
+ $start.RedirectStandardOutput = $true
+ $start.RedirectStandardError = $true
+ $start.WorkingDirectory = $taskRepoRoot
+ foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) }
+ $process = [Diagnostics.Process]::new()
+ $process.StartInfo = $start
+ $started = $false
+ $stdoutTask = $null
+ $stderrTask = $null
+ try {
+ $started = $process.Start()
+ if (!$started) { throw "Cannot start $Executable." }
+ $stdoutTask = $process.StandardOutput.ReadToEndAsync()
+ $stderrTask = $process.StandardError.ReadToEndAsync()
+ if (!$process.WaitForExit($TimeoutSeconds * 1000)) {
+ $process.Kill($true)
+ $process.WaitForExit(10000) | Out-Null
+ throw "$Executable exceeded the $TimeoutSeconds-second cleanup deadline. This is a hang deadline, not a performance threshold."
+ }
+ if ($process.ExitCode) { throw "$Executable failed with exit code $($process.ExitCode)." }
+ }
+ finally {
+ if ($started -and !$process.HasExited) { $process.Kill($true); $process.WaitForExit(10000) | Out-Null }
+ if ($null -ne $stdoutTask -and $stdoutTask.Wait(5000)) {
+ $text = $stdoutTask.GetAwaiter().GetResult()
+ [IO.File]::WriteAllText((Join-Path $taskOutputRoot "$Name.stdout.log"), $text)
+ [Console]::Out.Write($text)
+ }
+ if ($null -ne $stderrTask -and $stderrTask.Wait(5000)) {
+ $text = $stderrTask.GetAwaiter().GetResult()
+ [IO.File]::WriteAllText((Join-Path $taskOutputRoot "$Name.stderr.log"), $text)
+ [Console]::Error.Write($text)
+ }
+ $process.Dispose()
+ }
+}
+if ($PackageCache) {
+ $taskEmptyFeed = Join-Path $taskOutputRoot 'empty-feed'
+ New-Item -ItemType Directory -Path $taskEmptyFeed -Force | Out-Null
+ $taskRestoreOptions += @("-p:RestorePackagesPath=$([IO.Path]::GetFullPath($PackageCache))", '--source', $taskEmptyFeed)
+}
+if (!$NoBuild) {
+ $taskBuildArguments = @('build', (Join-Path $PSScriptRoot 'EditorWorkflow.csproj'), '-c', 'Release', '-v', 'minimal',
+ "-p:LibraryDirectory=$taskLibraryRoot", "-p:BaseOutputPath=$taskOutputRoot/bin/",
+ "-p:BaseIntermediateOutputPath=$taskOutputRoot/obj/") + $taskRestoreOptions
+ Invoke-WorkflowProcess 'dotnet' $taskBuildArguments 120 'build'
+}
+if ($BuildOnly) { return }
+$taskRunnerDirectory = Join-Path $taskOutputRoot 'bin/Release/net10.0'
+foreach ($name in @('SharpTS.LanguageServer.dll', 'SharpTS.dll')) {
+ $expected = (Get-FileHash -LiteralPath (Join-Path $taskLibraryRoot $name) -Algorithm SHA256).Hash
+ $copied = (Get-FileHash -LiteralPath (Join-Path $taskRunnerDirectory $name) -Algorithm SHA256).Hash
+ if ($copied -cne $expected) { throw "Stale benchmark copy of $name; rebuild against this LibraryDirectory before running." }
+}
+$taskRevision = & git -C $taskRepoRoot rev-parse HEAD
+Invoke-WorkflowProcess 'dotnet' @((Join-Path $taskRunnerDirectory 'SharpTS.Tests.dll'), $taskOutputRoot, 'results.json', $taskRevision) 240 'services'
+if (!$NoStdio) {
+ Invoke-WorkflowProcess 'node' @((Join-Path $PSScriptRoot 'stdio.mjs'), (Join-Path $taskLibraryRoot 'SharpTS.LanguageServer.dll'), $taskOutputRoot) 240 'stdio'
+}
diff --git a/benchmarks/editor-workflow/stdio.mjs b/benchmarks/editor-workflow/stdio.mjs
new file mode 100644
index 000000000..77653e928
--- /dev/null
+++ b/benchmarks/editor-workflow/stdio.mjs
@@ -0,0 +1,97 @@
+import assert from "node:assert/strict";
+import { performance } from "node:perf_hooks";
+import { createHash } from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { LspStdioClient } from "../../scripts/lib/lsp-stdio.mjs";
+
+const [serverArgument, outputArgument] = process.argv.slice(2);
+assert.ok(serverArgument && outputArgument, "usage: node stdio.mjs ");
+const server = path.resolve(serverArgument), output = path.resolve(outputArgument);
+const reports = [];
+const summary = (samples) => ({ count: samples.length,
+ medianMilliseconds: samples.map((sample) => sample.milliseconds).sort((a, b) => a - b)[Math.floor(samples.length / 2)],
+ p90Milliseconds: samples.map((sample) => sample.milliseconds).sort((a, b) => a - b)[Math.ceil(samples.length * .9) - 1],
+ payloadBytes: [...new Set(samples.map((sample) => sample.payloadBytes))],
+ fingerprints: [...new Set(samples.map((sample) => sample.fingerprint).filter(Boolean))], samples });
+for (const name of ["small", "multi-project", "default-library"]) {
+ const root = path.join(output, "fixtures", name);
+ const main = path.join(root, name === "multi-project" ? "apps/one/src/importer0.ts" : "main.ts");
+ const text = await fs.readFile(main, "utf8"), uri = pathToFileURL(main).href;
+ const at = (marker, delta = 0) => {
+ const offset = text.indexOf(marker) + marker.length + delta;
+ assert.ok(offset >= marker.length, `missing marker ${marker}`);
+ const before = text.slice(0, offset);
+ return { line: before.split("\n").length - 1, character: offset - before.lastIndexOf("\n") - 1 };
+ };
+ const queries = [
+ ["hover", "/*member*/", 1], ["completion", "/*member*/", 2], ["signatureHelp", "/*signature*/", 0],
+ ["definition", "/*member*/", 1], ["references", "/*member*/", 1],
+ ["prepareRename", "/*lexical*/", 1], ["prepareRename", "/*private*/", 2],
+ ];
+ const startup = [], first = [], warm = [], resultVariants = new Map();
+ for (let processIndex = 0; processIndex < 3; processIndex++) {
+ const started = performance.now();
+ const client = new LspStdioClient("dotnet", [server, "--language-features", "full", "--diagnostics", "all"],
+ { cwd: root, timeoutMs: 30_000 });
+ try {
+ const initialized = await client.request("initialize", { processId: process.pid,
+ rootUri: pathToFileURL(root).href, workspaceFolders: [{ uri: pathToFileURL(root).href, name }],
+ capabilities: { workspace: { workspaceEdit: { documentChanges: true } }, textDocument: {
+ hover: { contentFormat: ["markdown"] }, rename: { prepareSupport: true },
+ signatureHelp: { signatureInformation: { parameterInformation: { labelOffsetSupport: true }, activeParameterSupport: true } },
+ } } });
+ assert.equal(initialized.error, undefined);
+ startup.push({ milliseconds: performance.now() - started, payloadBytes: Buffer.byteLength(JSON.stringify(initialized)) });
+ client.notify("initialized", {});
+ client.notify("textDocument/didOpen", { textDocument: { uri, languageId: "typescript", version: 1, text } });
+ async function sequence() {
+ const begin = performance.now();
+ const results = [], payloads = [];
+ for (const [method, marker, delta] of queries) {
+ const response = await client.request(`textDocument/${method}`, {
+ textDocument: { uri }, position: at(marker, delta), context: { includeDeclaration: true },
+ });
+ assert.equal(response.error, undefined, `${name} ${method}: ${JSON.stringify(response.error)}`);
+ assert.ok(response.result, `${name} ${method}: result unavailable`);
+ if (method === "hover") assert.equal(response.result.contents.value, "```typescript\nvalue: any\n```", `${name} process ${processIndex} hover`);
+ if (method === "completion") assert.ok((Array.isArray(response.result) ? response.result : response.result.items)
+ .some((item) => item.label === "value"), `${name} process ${processIndex} completion`);
+ if (method === "signatureHelp") assert.ok(response.result.signatures?.length > 0);
+ if (method === "definition") assert.equal((Array.isArray(response.result) ? response.result : [response.result]).length, 1);
+ if (method === "references") assert.equal(response.result.length, name === "multi-project" ? 25 : 2);
+ if (method === "prepareRename" && marker === "/*private*/")
+ assert.equal(response.result.placeholder, "#serial");
+ if (method === "prepareRename" && marker === "/*lexical*/") assert.ok(response.result.start && response.result.end);
+ payloads.push(Buffer.byteLength(JSON.stringify(response)));
+ results.push(response.result);
+ }
+ const milliseconds = performance.now() - begin;
+ const normalized = JSON.stringify(results, (key, value) => {
+ // OmniSharp stamps a random per-process routing ID on completion data.
+ if (key === "$$__handler_id__$$") return "";
+ if (key === "uri" && typeof value === "string" && value.startsWith("file:")) {
+ const relative = path.relative(root, fileURLToPath(value));
+ if (!relative.startsWith("..") && !path.isAbsolute(relative)) return `/${relative.split(path.sep).join("/")}`;
+ }
+ return value;
+ });
+ const fingerprint = createHash("sha256").update(normalized).digest("hex");
+ if (!resultVariants.has(fingerprint)) resultVariants.set(fingerprint, results);
+ return { milliseconds, payloadBytes: payloads.reduce((a, b) => a + b, 0), perQueryPayloadBytes: payloads, fingerprint };
+ }
+ first.push(await sequence());
+ if (processIndex === 0) for (let i = 0; i < 15; i++) warm.push(await sequence());
+ } finally { await client.close(); }
+ }
+ if (resultVariants.size !== 1) await fs.writeFile(path.join(output, `stdio-${name}-variants.json`), JSON.stringify([...resultVariants], null, 2));
+ assert.equal(new Set([...first, ...warm].map((sample) => sample.fingerprint)).size, 1, `${name}: wire result changed`);
+ reports.push({ name, freshProcessInitialize: summary(startup), firstSequenceAfterInitialize: summary(first),
+ unchangedWarmSequence: summary(warm) });
+}
+const report = { timestampUtc: new Date().toISOString(), node: process.version, platform: `${process.platform}-${process.arch}`,
+ server, serverSha256: createHash("sha256").update(await fs.readFile(server)).digest("hex"),
+ methodology: "Actual full-mode stdio using the same on-disk service fixtures. Three fresh processes per fixture; initialize includes spawn/startup separately from the first request sequence. Fifteen unchanged sequences in the first process. Byte counts are parsed JSON response bytes (including JSON-RPC fields, excluding Content-Length framing), not raw pipe framing or notifications. Fingerprints normalize fixture URIs and only OmniSharp's random per-process completion routing ID; semantic fields remain intact. No internal server counters are observed in this run. Diagnostic publication uses the standalone all preset and may share/contend for analysis, as in a real client.", reports };
+await fs.writeFile(path.join(output, "stdio-results.json"), JSON.stringify(report, null, 2) + "\n");
+console.log(path.join(output, "stdio-results.json"));
diff --git a/benchmarks/editor-workflow/summarize.mjs b/benchmarks/editor-workflow/summarize.mjs
new file mode 100644
index 000000000..c4c7b050e
--- /dev/null
+++ b/benchmarks/editor-workflow/summarize.mjs
@@ -0,0 +1,63 @@
+import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const [workflowArgument, equivalentArgument, outputArgument] = process.argv.slice(2);
+assert.ok(workflowArgument && equivalentArgument && outputArgument,
+ "usage: node summarize.mjs ");
+const read = async (root, name) => JSON.parse(await fs.readFile(path.join(root, name), "utf8"));
+const workflow = await read(workflowArgument, "results.json");
+const stdio = await read(workflowArgument, "stdio-results.json");
+const baseline = await read(equivalentArgument, "baseline.json");
+const current = await read(equivalentArgument, "current.json");
+const compareReports = (oldReport, newReport) => oldReport.reports.map((before) => {
+ const after = newReport.reports.find((report) => report.name === before.name);
+ assert.ok(after);
+ assert.equal(before.cold.resultFingerprint, after.cold.resultFingerprint);
+ const compare = (phase) => ({ baseline: before[phase], current: after[phase],
+ medianChangePercent: (after[phase].medianMilliseconds / before[phase].medianMilliseconds - 1) * 100,
+ allocatedChangePercent: (after[phase].medianAllocatedBytes / before[phase].medianAllocatedBytes - 1) * 100 });
+ return { name: before.name, cold: compare("cold"), warm: compare("warm"),
+ retention: { baseline: before.retention, current: after.retention }, validation: after.validation };
+});
+const equivalent = compareReports(baseline, current);
+let repeat;
+try {
+ const before = await read(equivalentArgument, "baseline-repeat.json"), after = await read(equivalentArgument, "current-repeat.json");
+ repeat = { baselineTimestampUtc: before.timestampUtc, currentTimestampUtc: after.timestampUtc, reports: compareReports(before, after) };
+} catch (error) { if (error.code !== "ENOENT") throw error; }
+// This counter covers published SourceDocuments. Embedded default .d.ts modules
+// currently have no SourceDocument and were never included in the measured count.
+for (const item of workflow.reports) if (item.checkedDocumentsIncludingLibraries !== undefined) {
+ item.sourceDocumentsInEntrySnapshot = item.checkedDocumentsIncludingLibraries;
+ delete item.checkedDocumentsIncludingLibraries;
+}
+function compact(value) {
+ if (Array.isArray(value)) return value.map(compact);
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value)
+ .filter(([key]) => key !== "samples").map(([key, item]) => [key,
+ key === "server" ? path.basename(item) : compact(item)]));
+ return value;
+}
+const hash = async (file) => createHash("sha256").update(await fs.readFile(file)).digest("hex");
+const runnerDirectory = path.dirname(fileURLToPath(import.meta.url));
+const replaySourceSha256 = Object.fromEntries(await Promise.all(["Program.cs", "EditorWorkflow.csproj", "run.ps1", "stdio.mjs", "summarize.mjs"]
+ .map(async (file) => [file, await hash(path.join(runnerDirectory, file))])));
+const equivalentBinaries = {};
+for (const name of ["baseline", "current"]) equivalentBinaries[name] = {
+ languageServerSha256: await hash(path.join(equivalentArgument, "bin", name, "Release/net10.0/SharpTS.LanguageServer.dll")),
+ compilerSha256: await hash(path.join(equivalentArgument, "bin", name, "Release/net10.0/SharpTS.dll")),
+};
+workflow.metadata.referenceAssemblyBytes = {
+ core: (await fs.stat(path.join(equivalentArgument, "bin/current/Release/net10.0/SharpTS.dll"))).size,
+ languageServer: (await fs.stat(path.join(equivalentArgument, "bin/current/Release/net10.0/SharpTS.LanguageServer.dll"))).size,
+};
+const report = { schemaVersion: 1, replaySourceSha256, workflow, stdio, equivalentAnalysis: {
+ baselineCommit: "df4589b7", baselineTimestampUtc: baseline.timestampUtc, currentTimestampUtc: current.timestampUtc,
+ environment: { runtime: baseline.runtime, os: baseline.os, architecture: baseline.architecture, processorCount: baseline.processorCount },
+ binaries: equivalentBinaries, methodology: baseline.methodology, reports: equivalent, freshProcessRepeat: repeat,
+}, limits: "Single-machine measurements. New feature sequences report absolute costs. Only the unchanged pre-existing definition/reference/statements/diagnostics sequence has an exact historical baseline. Allocation churn, estimated completed-cache bytes and full-GC live heap are distinct. Stdio counters are unobserved. Raw samples remain in ignored artifacts." };
+await fs.writeFile(outputArgument, JSON.stringify(compact(report), null, 2) + "\n");
+console.log(outputArgument);
diff --git a/benchmarks/member-provenance/MemberProvenance.csproj b/benchmarks/member-provenance/MemberProvenance.csproj
new file mode 100644
index 000000000..a50133fb8
--- /dev/null
+++ b/benchmarks/member-provenance/MemberProvenance.csproj
@@ -0,0 +1,14 @@
+
+
+ net10.0
+ Exe
+ SharpTS.MemberBenchmark
+ enable
+ enable
+ false
+ $(DefineConstants);BASELINE
+
+
+
+
+
diff --git a/benchmarks/member-provenance/Program.cs b/benchmarks/member-provenance/Program.cs
new file mode 100644
index 000000000..2a8bdd9d4
--- /dev/null
+++ b/benchmarks/member-provenance/Program.cs
@@ -0,0 +1,312 @@
+using System.Collections;
+using System.Diagnostics;
+using System.Globalization;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using SharpTS.Diagnostics;
+using SharpTS.Configuration;
+using SharpTS.Modules;
+using SharpTS.Parsing;
+using SharpTS.TypeSystem;
+using TypeInfo = SharpTS.TypeSystem.TypeInfo;
+
+const int sampleCount = 31;
+VerifyNominalFingerprint();
+var reports = new List();
+foreach (bool multi in new[] { false, true })
+{
+ foreach (bool capture in new[] { false, true })
+ {
+#if BASELINE
+ if (capture) continue;
+#endif
+ int repetitions = multi ? 2 : 12;
+ for (int index = 0; index < 30; index++) Check(Prepare(multi), capture);
+ var times = new List();
+ var allocations = new List();
+ Graph? lastGraph = null;
+ Published? last = null;
+ for (int sample = 0; sample < sampleCount; sample++)
+ {
+ // The checker updates modules and AST-adjacent state. Every timed check receives a
+ // fresh editor-enabled parsed graph, with all setup excluded from the counters.
+ Graph[] graphs = Enumerable.Range(0, repetitions).Select(_ => Prepare(multi)).ToArray();
+ GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ long started = Stopwatch.GetTimestamp();
+ foreach (Graph graph in graphs) last = Check(graph, capture);
+ times.Add(Stopwatch.GetElapsedTime(started).TotalMilliseconds / repetitions);
+ allocations.Add((GC.GetAllocatedBytesForCurrentThread() - before) / repetitions);
+ lastGraph = graphs[^1];
+ GC.KeepAlive(graphs);
+ }
+ times.Sort(); allocations.Sort();
+ var fingerprints = Fingerprints(lastGraph!, last!);
+ reports.Add(new
+ {
+ fixture = multi ? "25-module-inheritance-generics" : "small",
+ memberCapture = capture,
+ sourceFiles = lastGraph!.Modules.Count,
+ sourceUtf16Bytes = lastGraph.Modules.Sum(module => module.Document!.Text.Length * sizeof(char)),
+ repetitionsPerSample = repetitions,
+ timedChecks = sampleCount * repetitions,
+ medianMilliseconds = times[sampleCount / 2],
+ p90Milliseconds = times[(int)Math.Ceiling(sampleCount * .9) - 1],
+ medianAllocatedBytes = allocations[sampleCount / 2],
+ retainedPublishedHeapDeltaBytes = Retained(multi, capture),
+ diagnosticCount = last!.Diagnostics.Count,
+ typedExpressions = lastGraph.Expressions.Count(expr => last.Types.Get(expr) is not null),
+ lexicalTokenCount = lastGraph.Tokens.Count,
+#if BASELINE
+ memberSymbols = 0,
+ memberOccurrences = 0,
+ memberEstimatedBytes = 0L,
+#else
+ memberSymbols = last.Members.Symbols.Count,
+ memberOccurrences = last.Members.Occurrences.Count,
+ memberEstimatedBytes = last.Members.EstimatedBytes,
+#endif
+ fingerprints,
+ });
+ }
+}
+var report = new
+{
+ implementation = args[1],
+ timestampUtc = DateTime.UtcNow,
+ runtime = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription,
+ os = System.Runtime.InteropServices.RuntimeInformation.OSDescription,
+ architecture = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString(),
+ processorCount = Environment.ProcessorCount,
+ tieredCompilation = Environment.GetEnvironmentVariable("DOTNET_TieredCompilation") ?? "runtime default",
+ samples = sampleCount,
+ methodology = "Fresh pure-VFS editor-enabled parsed module graph for each check; parse/resolution/setup excluded. Timed: checker construction, CheckModules, lexical/member freeze and diagnostics copy. 30 warmups, 31 full-GC-isolated batches. Fingerprints outside timing: diagnostics, public structural expression/class types (nominal class IDs normalized to per-graph ordinals, retaining identity partitions), lexical declaration/use identity partitions. Retention: full-GC live heap delta from an already parsed graph to a checked publication with checker released. Baseline archived without instrumentation; no disk I/O in timed checks.",
+ reports,
+};
+File.WriteAllText(args[0], JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }));
+Console.WriteLine(args[0]);
+
+static Graph Prepare(bool multi)
+{
+ string folder = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "SharpTS-Member-Benchmark"));
+ int count = multi ? 25 : 1;
+ var sources = Enumerable.Range(0, count).ToDictionary(
+ index => Path.Combine(folder, $"module{index}.ts"),
+ index => multi ? MultiSource(index) : SmallSource(), StringComparer.OrdinalIgnoreCase);
+ string entryPath = Path.Combine(folder, $"module{count - 1}.ts");
+ var resolver = new ModuleResolver(entryPath, ModuleResolutionOptions.Default, sources,
+ new TypeScriptProgramOptions { NoLib = true, Types = [] }) { CaptureEditorSyntax = true };
+ var modules = resolver.GetModulesInOrder(resolver.LoadProgram(entryPath));
+ var expressions = modules.SelectMany(module => module.Document!.EditorSyntax!.Records)
+ .Select(record => record.Node).OfType().Distinct(ReferenceEqualityComparer.Instance).ToArray();
+ var tokens = modules.SelectMany(module => module.Tokens.Select(token => (module.Document!, token)))
+ .Where(pair => pair.token.Start >= 0 && pair.token.End > pair.token.Start).ToArray();
+ return new(resolver, modules, expressions, tokens);
+}
+
+[MethodImpl(MethodImplOptions.NoInlining)]
+static Published Check(Graph graph, bool capture)
+{
+ var checker = new TypeChecker();
+#if !BASELINE
+ if (capture) checker.WithMemberProvenance();
+#endif
+ TypeMap types = checker.CheckModules(graph.Modules, graph.Resolver);
+ var bindings = checker.Bindings.Freeze();
+ var diagnostics = checker.GetDiagnostics().ToArray();
+#if BASELINE
+ return new(types, bindings, diagnostics);
+#else
+ return new(types, bindings, diagnostics, checker.Members.Freeze());
+#endif
+}
+
+[MethodImpl(MethodImplOptions.NoInlining)]
+static long Retained(bool multi, bool capture)
+{
+ Graph graph = Prepare(multi);
+ GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
+ long before = GC.GetTotalMemory(forceFullCollection: true);
+ Published held = Check(graph, capture);
+ long after = GC.GetTotalMemory(forceFullCollection: true);
+ GC.KeepAlive(graph); GC.KeepAlive(held);
+ return after - before;
+}
+
+static object Fingerprints(Graph graph, Published published)
+{
+ var diagnosticText = new StringBuilder();
+ foreach (Diagnostic diagnostic in published.Diagnostics.OrderBy(item => item.FilePath).ThenBy(item => item.Line)
+ .ThenBy(item => item.Column).ThenBy(item => item.Code).ThenBy(item => item.Message))
+ diagnosticText.Append(Path.GetFileName(diagnostic.FilePath)).Append(':').Append(diagnostic.Line)
+ .Append(':').Append(diagnostic.Column).Append(':').Append(diagnostic.Severity).Append(':')
+ .Append(diagnostic.Code).Append(':').Append(diagnostic.TsCode).Append(':').Append(diagnostic.Message).AppendLine();
+
+ var types = new List();
+ foreach (ParsedModule module in graph.Modules)
+ {
+ types.Add(module.ModuleName);
+ foreach (EditorSyntaxRecord syntax in module.Document!.EditorSyntax!.Records
+ .Where(record => record.Node is Expr).DistinctBy(record => record.Node, ReferenceEqualityComparer.Instance))
+ {
+ types.Add(syntax.Span.Start); types.Add(syntax.Span.End); types.Add(syntax.Node.GetType().Name);
+ types.Add(published.Types.Get((Expr)syntax.Node));
+ }
+ }
+ types.Add(published.Types.ClassTypes);
+
+ var lexical = new StringBuilder();
+ foreach ((SourceDocument document, Token token) in graph.Tokens)
+ {
+ var symbols = published.Bindings.FindSymbols(document, token.Start)
+ .OrderBy(symbol => symbol.Namespace).ThenBy(symbol => symbol.Name).ToArray();
+ if (symbols.Length == 0) continue;
+ lexical.Append(Path.GetFileName(document.Path)).Append(':').Append(token.Start).Append(':').Append(token.End);
+ foreach (FrozenBindingSymbol symbol in symbols)
+ {
+ lexical.Append('|').Append(symbol.Namespace).Append(':').Append(symbol.Name).Append(" declarations[");
+ foreach (BindingDeclaration declaration in symbol.Declarations.OrderBy(item => item.Document.Path).ThenBy(item => item.Name.Start))
+ lexical.Append(Path.GetFileName(declaration.Document.Path)).Append(':').Append(declaration.Name.Start).Append(',');
+ lexical.Append("] uses[");
+ foreach (BindingOccurrence occurrence in published.Bindings.FindReferences([symbol], true))
+ lexical.Append(Path.GetFileName(occurrence.Document.Path)).Append(':').Append(occurrence.Name.Start)
+ .Append(':').Append(occurrence.IsDeclaration).Append(',');
+ lexical.Append(']');
+ }
+ lexical.AppendLine();
+ }
+ return new { diagnostics = Hash(diagnosticText.ToString()), types = StructuralHash(types), lexical = Hash(lexical.ToString()) };
+}
+
+static string StructuralHash(object root)
+{
+ var text = new StringBuilder();
+ var visited = new Dictionary(ReferenceEqualityComparer.Instance);
+ var nominalClasses = new Dictionary();
+ Write(root);
+ return Hash(text.ToString());
+ void Write(object? value)
+ {
+ if (value is null) { text.Append("null;"); return; }
+ Type type = value.GetType();
+ if (value is string word) { text.Append(word.Length).Append(':').Append(word).Append(';'); return; }
+ if (type.IsPrimitive || type.IsEnum || value is decimal or System.Numerics.BigInteger)
+ { text.Append(type.Name).Append(':').Append(Convert.ToString(value, CultureInfo.InvariantCulture)).Append(';'); return; }
+ if (visited.TryGetValue(value, out int identity)) { text.Append('@').Append(identity).Append(';'); return; }
+ visited[value] = visited.Count;
+ // Concrete frozen dictionary implementation names/bucket order are runtime details.
+ bool dictionary = type.GetInterfaces().Any(item => item.IsGenericType &&
+ item.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>));
+ text.Append(dictionary ? "dictionary" : type.FullName).Append('{');
+ if (value is IEnumerable sequence)
+ {
+ object?[] items = sequence.Cast().ToArray();
+ if (dictionary) items = items.OrderBy(item => item!.GetType().GetProperty("Key")!.GetValue(item)?.ToString(), StringComparer.Ordinal).ToArray();
+ else if (type.GetInterfaces().Any(item => item.IsGenericType && item.GetGenericTypeDefinition() == typeof(ISet<>)))
+ items = items.OrderBy(item => item?.ToString(), StringComparer.Ordinal).ToArray();
+ foreach (object? item in items) Write(item);
+ }
+ else
+ foreach (PropertyInfo property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
+ .Where(property => property.GetIndexParameters().Length == 0)
+ .OrderBy(property => property.Name, StringComparer.Ordinal))
+ {
+ text.Append(property.Name).Append('=');
+ if ((property.Name == "DeclarationId" && value is ClassMetadataCore or TypeInfo.MutableClass) ||
+ (property.Name == "DeclaringClassId" && value is MemberAccessBrand))
+ {
+ int nominalId = (int)property.GetValue(value)!;
+ // Global numeric IDs differ between fresh checks/processes. Keep zero as
+ // locationless and preserve every nonzero identity's equivalence partition.
+ if (nominalId == 0) text.Append("class:0;");
+ else
+ {
+ if (!nominalClasses.TryGetValue(nominalId, out int ordinal))
+ nominalClasses.Add(nominalId, ordinal = nominalClasses.Count + 1);
+ text.Append("class:").Append(ordinal).Append(';');
+ }
+ }
+ else Write(property.GetValue(value));
+ }
+ text.Append('}');
+ }
+}
+
+static string Hash(string text) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text)));
+
+static void VerifyNominalFingerprint()
+{
+ object[] distinct = [new TypeInfo.MutableClass("Same", 101), new TypeInfo.MutableClass("Same", 102),
+ new MemberAccessBrand(AccessModifier.Private, 101), new MemberAccessBrand(AccessModifier.Protected, 102)];
+ object[] remapped = [new TypeInfo.MutableClass("Same", 901), new TypeInfo.MutableClass("Same", 902),
+ new MemberAccessBrand(AccessModifier.Private, 901), new MemberAccessBrand(AccessModifier.Protected, 902)];
+ object[] merged = [new TypeInfo.MutableClass("Same", 901), new TypeInfo.MutableClass("Same", 901),
+ new MemberAccessBrand(AccessModifier.Private, 901), new MemberAccessBrand(AccessModifier.Protected, 901)];
+ object[] locationless = [new TypeInfo.MutableClass("Same", 0), new TypeInfo.MutableClass("Same", 902),
+ new MemberAccessBrand(AccessModifier.Private, 0), new MemberAccessBrand(AccessModifier.Protected, 902)];
+ object[] wrongBrands = [new TypeInfo.MutableClass("Same", 901), new TypeInfo.MutableClass("Same", 902),
+ new MemberAccessBrand(AccessModifier.Private, 902), new MemberAccessBrand(AccessModifier.Protected, 901)];
+ string expected = StructuralHash(distinct);
+ if (expected != StructuralHash(remapped) || expected == StructuralHash(merged) ||
+ expected == StructuralHash(locationless) || expected == StructuralHash(wrongBrands))
+ throw new InvalidOperationException("Nominal type fingerprint must preserve identity partitions and locationless zero.");
+}
+
+static string SmallSource() => """
+ export class Counter {
+ value: number = 1;
+ #secret: number = 2;
+ static total: number = 0;
+ get doubled(): number { return this.value * 2; }
+ set doubled(next: number) { this.value = next / 2; }
+ increment(delta: number): number { this.value = this.value + delta; return this.#secret + this.value; }
+ has(other: Counter): boolean { return #secret in other; }
+ }
+ const counter = new Counter();
+ counter.value = counter.increment(3);
+ counter.doubled = counter["value"];
+ const result: number = counter.doubled + Counter.total;
+ """;
+
+static string MultiSource(int index) => $$"""
+ {{(index == 0 ? "" : $"import {{ Box{index - 1} as Previous }} from './module{index - 1}';")}}
+ export class Base{{index}} {
+ value: number = {{index}};
+ #secret: number = {{index + 1}};
+ static total: number = 0;
+ get doubled(): number { return this.value * 2; }
+ set doubled(next: number) { this.value = next / 2; }
+ increment(delta: number): number { this.value = this.value + delta; return this.#secret + this.value; }
+ has(other: Base{{index}}): boolean { return #secret in other; }
+ }
+ export class Derived{{index}} extends Base{{index}} {
+ own: number = 2;
+ sum(): number { return this.increment(this.own) + this.value; }
+ }
+ export class Box{{index}} {
+ value: T;
+ constructor(value: T) { this.value = value; }
+ read(): T { return this.value; }
+ update(next: T): void { this.value = next; }
+ }
+ export function run{{index}}(): number {
+ const derived = new Derived{{index}}();
+ derived.value = derived.increment(3);
+ derived.doubled = derived["value"];
+ const box = new Box{{index}}(derived.sum());
+ box.update(box.read() + Base{{index}}.total);
+ {{(index == 0 ? "" : "const previous = new Previous(1); box.update(previous.read());")}}
+ return box.value + derived.doubled;
+ }
+ """;
+
+sealed record Graph(ModuleResolver Resolver, List Modules, IReadOnlyList Expressions,
+ IReadOnlyList<(SourceDocument Document, Token Token)> Tokens);
+sealed record Published(TypeMap Types, FrozenBindingIndex Bindings, IReadOnlyList Diagnostics
+#if !BASELINE
+ , FrozenMemberIndex Members
+#endif
+);
diff --git a/benchmarks/member-provenance/README.md b/benchmarks/member-provenance/README.md
new file mode 100644
index 000000000..71aee930c
--- /dev/null
+++ b/benchmarks/member-provenance/README.md
@@ -0,0 +1,26 @@
+This optional runner isolates the checker cost of source member provenance. It is outside the solution and has no effect on normal builds.
+
+Run from the repository root with .NET 10 and PowerShell:
+
+```powershell
+./benchmarks/member-provenance/run.ps1
+```
+
+The script archives the exact `f620d279` baseline into ignored `artifacts/member-provenance-benchmark`, without source instrumentation. It builds that compiler and the current compiler, then builds the same runner against each DLL directory. `-PackageCache ` supports an existing offline NuGet cache. `-OutputDirectory ` chooses another scratch directory; `-SkipLibraryBuilds` reuses already built libraries there.
+
+Each check receives a fresh graph already parsed with editor syntax enabled. The small fixture covers instance/static/private members, accessors, literal indexing, calls, writes and private presence. The 25-module fixture adds inherited members, generic classes and imported generic receivers. Pure VFS resolution disables disk fallback and default declaration libraries. Preparation, lexing, parsing, resolution and fingerprinting occur outside the measured interval. Timed work includes constructing a checker, checking the graph, freezing lexical/member indexes and copying diagnostics. The baseline and capture-disabled current variant both freeze the existing lexical index; the enabled variant also freezes member provenance.
+
+There are 30 warmups and 31 batched samples per variant. Tiered compilation is disabled for reproducibility. Every batch starts after a full GC with its fresh graphs alive. Results include median/p90 latency, per-thread allocated bytes and a noisy full-GC live heap delta from a prepared graph to a retained checked publication. The publication holds types, diagnostics and frozen indexes, without the mutable checker. The member estimate is the index's documented approximation, separate from actual heap measurements.
+
+The script rejects differences in three fingerprints computed outside timing: diagnostic contents/positions; public structural expression/class types; and lexical declaration/use identity partitions. Existing numeric nominal class IDs are normalized to ordinals in deterministic graph-traversal order, including class IDs in access brands. Zero remains distinct, and all nonzero identity equivalence partitions remain in the fingerprint. IDs are neither omitted nor merged by spelling or source shape. Other fields, source ranges and declaration/use groupings remain in the fingerprints. Fixtures exclude parameter properties because their intentional rename-eligibility policy change is verified by focused tests. The runner measures cold checking of a warm process; it does not measure snapshot cache hits, editor latency, CLR metadata or parsing.
+
+The recorded Windows ARM64/.NET 10.0.12 run is in [results-2026-10-07.json](results-2026-10-07.json). All three fingerprints match across the baseline and both current variants. Median ordinary checking changes by +2.55%/-4.04% for the small/multi-module fixtures; allocation changes are +0.207%/+0.230%. Neither ordinary case exceeds the 10% investigation threshold. This single-machine run does not establish a speedup or process-to-process variance.
+
+| Fixture | Baseline median | Current, capture off | Current, capture on | Extra cold check | Extra allocated / retained bytes |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| Small | 0.1328 ms | 0.1361 ms | 0.3224 ms | 0.1863 ms | 170,216 / 11,904 |
+| 25 modules | 87.0108 ms | 83.4938 ms | 101.7982 ms | 18.3045 ms | 8,880,176 / 583,904 |
+
+Enabled checking is 2.368×/1.219× the current capture-disabled time and allocates 2.513×/2.369× as many bytes. Those allocations are transient work as well as retained output; they are not heap growth. Code inspection identifies repeated declaration grouping, actual-lookup proof scopes and candidate combination, owner fact maps, and frozen copies/sorts as additional work. This is an attribution from code inspection, not measured phase profiling. The extra retained output is about 476/477 bytes per published symbol or occurrence across these two fixtures, consistent with collection/object overhead scaling with the captured records.
+
+The frozen index reports 6 symbols/19 occurrences and 300 symbols/923 occurrences. Its estimates are 4,896/247,104 bytes, smaller than measured additional retained heap of 11,904/583,904 bytes. Estimates omit some runtime collection/object overhead. The snapshot cache uses an estimated memory budget, not a strict process heap limit. Source text, parser metadata and normal checking remain separate costs. Existing snapshot reuse avoids paying the cold check on every request; final epic validation in #1981 still needs end-to-end interactive measurements before drawing conclusions about editor latency.
diff --git a/benchmarks/member-provenance/results-2026-10-07.json b/benchmarks/member-provenance/results-2026-10-07.json
new file mode 100644
index 000000000..09026cb9b
--- /dev/null
+++ b/benchmarks/member-provenance/results-2026-10-07.json
@@ -0,0 +1,137 @@
+{
+ "baselineCommit": "f620d279",
+ "current": "Uncommitted #1392 implementation on f620d279, including final source-owner aliases and Presence hardening.",
+ "baselineTimestampUtc": "2026-10-07T06:44:50.8383116Z",
+ "currentTimestampUtc": "2026-10-07T06:45:26.5769172Z",
+ "environment": {
+ "sdk": "10.0.401",
+ "runtime": ".NET 10.0.12",
+ "os": "Microsoft Windows 10.0.26220",
+ "architecture": "Arm64",
+ "processorCount": 12,
+ "tieredCompilation": "0"
+ },
+ "samples": 31,
+ "warmupsPerVariant": 30,
+ "methodology": "Fresh pure-VFS editor-enabled parsed module graph for each check; parse/resolution/setup excluded. Timed: checker construction, CheckModules, lexical/member freeze and diagnostics copy. 30 warmups, 31 full-GC-isolated batches. Fingerprints outside timing: diagnostics, public structural expression/class types (nominal class IDs normalized to per-graph ordinals, retaining identity partitions), lexical declaration/use identity partitions. Retention: full-GC live heap delta from an already parsed graph to a checked publication with checker released. Baseline archived without instrumentation; no disk I/O in timed checks.",
+ "replay": "./benchmarks/member-provenance/run.ps1 -PackageCache ",
+ "interpretation": [
+ "No ordinary median regression exceeds 10%; this single-machine run shows allocations grow below 0.24%.",
+ "Opt-in provenance adds cold checker allocation churn: +170216B small and +8880176B multi-module; retained publication grows only +11904B and +583904B respectively.",
+ "Index estimates of 4896B/247104B are approximate and smaller than measured retained deltas; the snapshot budget is estimated memory, not a strict heap cap.",
+ "Required enabled cold-check cost is +0.1863ms/+18.3045ms. Reused snapshots avoid repeated checks; final #1981 must measure end-to-end interactive latency.",
+ "Code inspection attributes added work to repeated declaration grouping, lookup proof scopes/candidate combination, owner fact maps and freezing/copies/sorts; no measured phase attribution or profiler was used."
+ ],
+ "fixtures": [
+ {
+ "name": "small",
+ "sourceFiles": 1,
+ "sourceUtf16Bytes": 1086,
+ "typedExpressions": 43,
+ "sourceTokens": 141,
+ "diagnosticCount": 0,
+ "repetitionsPerSample": 12,
+ "timedChecksPerVariant": 372,
+ "fingerprints": {
+ "diagnostics": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
+ "types": "6B7A442668A8AF3E2BC50FA6FDFF79AA55AAB7D9936303296E32D0E093E5A588",
+ "lexical": "ADD150DA6C0F972C1E46D4DE896564EEA26FABE1ACB2724EA0E9ADD8523FFC90"
+ },
+ "allVariantFingerprintsMatch": true,
+ "variants": {
+ "baseline": {
+ "medianMilliseconds": 0.13275,
+ "p90Milliseconds": 0.15345,
+ "medianAllocatedBytes": 112281,
+ "retainedPublishedHeapDeltaBytes": 19800,
+ "memberSymbols": 0,
+ "memberOccurrences": 0,
+ "memberEstimatedBytes": 0
+ },
+ "currentCaptureOff": {
+ "medianMilliseconds": 0.13614166666666666,
+ "p90Milliseconds": 0.15180000000000002,
+ "medianAllocatedBytes": 112513,
+ "retainedPublishedHeapDeltaBytes": 19864,
+ "memberSymbols": 0,
+ "memberOccurrences": 0,
+ "memberEstimatedBytes": 0
+ },
+ "currentCaptureOn": {
+ "medianMilliseconds": 0.32240833333333335,
+ "p90Milliseconds": 0.37643333333333334,
+ "medianAllocatedBytes": 282729,
+ "retainedPublishedHeapDeltaBytes": 31768,
+ "memberSymbols": 6,
+ "memberOccurrences": 19,
+ "memberEstimatedBytes": 4896
+ }
+ },
+ "comparison": {
+ "ordinaryMedianPercent": 2.554927809165086,
+ "ordinaryAllocationPercent": 0.2066244511538029,
+ "enabledTimeMultiplier": 2.3681826528738448,
+ "enabledExtraMilliseconds": 0.1862666666666667,
+ "enabledAllocationMultiplier": 2.5128562921617945,
+ "enabledExtraAllocatedBytes": 170216,
+ "enabledExtraRetainedBytes": 11904,
+ "enabledRetainedMultiplier": 1.5992750704792589
+ }
+ },
+ {
+ "name": "25-module-inheritance-generics",
+ "sourceFiles": 25,
+ "sourceUtf16Bytes": 54508,
+ "typedExpressions": 2091,
+ "sourceTokens": 7393,
+ "diagnosticCount": 0,
+ "repetitionsPerSample": 2,
+ "timedChecksPerVariant": 62,
+ "fingerprints": {
+ "diagnostics": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855",
+ "types": "0CFA3D40060B6F578C57BEB7EEA267A6102D09C8F22275158E4DF903A41D06DF",
+ "lexical": "408E378035EEDD8C8FF24D4999337F42418B84D17F5C431CDC2EF4E8F4BDB95A"
+ },
+ "allVariantFingerprintsMatch": true,
+ "variants": {
+ "baseline": {
+ "medianMilliseconds": 87.01075,
+ "p90Milliseconds": 111.64775,
+ "medianAllocatedBytes": 6470792,
+ "retainedPublishedHeapDeltaBytes": 827176,
+ "memberSymbols": 0,
+ "memberOccurrences": 0,
+ "memberEstimatedBytes": 0
+ },
+ "currentCaptureOff": {
+ "medianMilliseconds": 83.49375,
+ "p90Milliseconds": 86.6228,
+ "medianAllocatedBytes": 6485696,
+ "retainedPublishedHeapDeltaBytes": 830376,
+ "memberSymbols": 0,
+ "memberOccurrences": 0,
+ "memberEstimatedBytes": 0
+ },
+ "currentCaptureOn": {
+ "medianMilliseconds": 101.7982,
+ "p90Milliseconds": 105.9067,
+ "medianAllocatedBytes": 15365872,
+ "retainedPublishedHeapDeltaBytes": 1414280,
+ "memberSymbols": 300,
+ "memberOccurrences": 923,
+ "memberEstimatedBytes": 247104
+ }
+ },
+ "comparison": {
+ "ordinaryMedianPercent": -4.042029289484339,
+ "ordinaryAllocationPercent": 0.23032729223873005,
+ "enabledTimeMultiplier": 1.2192313795942809,
+ "enabledExtraMilliseconds": 18.30444999999999,
+ "enabledAllocationMultiplier": 2.369193992441212,
+ "enabledExtraAllocatedBytes": 8880176,
+ "enabledExtraRetainedBytes": 583904,
+ "enabledRetainedMultiplier": 1.7031802460572079
+ }
+ }
+ ]
+}
diff --git a/benchmarks/member-provenance/run.ps1 b/benchmarks/member-provenance/run.ps1
new file mode 100644
index 000000000..e51f4f390
--- /dev/null
+++ b/benchmarks/member-provenance/run.ps1
@@ -0,0 +1,58 @@
+param(
+ [string]$BaselineCommit = 'f620d279',
+ [string]$PackageCache = '',
+ [string]$OutputDirectory = '',
+ [switch]$SkipLibraryBuilds
+)
+$ErrorActionPreference = 'Stop'
+$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
+if (!$OutputDirectory) { $OutputDirectory = Join-Path $repoRoot 'artifacts/member-provenance-benchmark' }
+$outputRoot = [IO.Path]::GetFullPath($OutputDirectory)
+New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null
+$baselineRoot = Join-Path $outputRoot ('baseline-' + $BaselineCommit)
+$baselineArchive = Join-Path $outputRoot ('baseline-' + $BaselineCommit + '.zip')
+if (!(Test-Path -LiteralPath $baselineRoot)) {
+ & git -C $repoRoot archive --format=zip "--output=$baselineArchive" $BaselineCommit
+ if ($LASTEXITCODE) { throw 'git archive failed.' }
+ Expand-Archive -LiteralPath $baselineArchive -DestinationPath $baselineRoot
+}
+function Invoke-CheckedDotNet([string[]]$DotNetArguments) {
+ & dotnet @DotNetArguments
+ if ($LASTEXITCODE) { throw "dotnet failed: $($DotNetArguments[0])" }
+}
+$restoreOptions = @('-p:NuGetAudit=false', '-p:MinVerVersionOverride=0.0.0-benchmark')
+if ($PackageCache) {
+ $emptyFeed = Join-Path $outputRoot 'empty-feed'
+ New-Item -ItemType Directory -Path $emptyFeed -Force | Out-Null
+ $restoreOptions += @("-p:RestorePackagesPath=$([IO.Path]::GetFullPath($PackageCache))", '--source', $emptyFeed)
+}
+if (!$SkipLibraryBuilds) {
+ Invoke-CheckedDotNet (@('build', (Join-Path $baselineRoot 'src/SharpTS/SharpTS.csproj'), '-c', 'Release', '-v', 'minimal') + $restoreOptions)
+ Invoke-CheckedDotNet (@('build', (Join-Path $repoRoot 'src/SharpTS/SharpTS.csproj'), '-c', 'Release', '-v', 'minimal') + $restoreOptions)
+}
+foreach ($implementation in @('baseline', 'current')) {
+ $libraryRoot = if ($implementation -eq 'baseline') { $baselineRoot } else { $repoRoot }
+ $runnerOptions = @('build', (Join-Path $PSScriptRoot 'MemberProvenance.csproj'), '-c', 'Release', '-v', 'minimal',
+ "-p:LibraryDirectory=$libraryRoot/src/SharpTS/bin/Release/net10.0",
+ "-p:BaseOutputPath=$outputRoot/bin/$implementation/", "-p:BaseIntermediateOutputPath=$outputRoot/obj/$implementation/")
+ if ($implementation -eq 'baseline') { $runnerOptions += '-p:Baseline=true' }
+ Invoke-CheckedDotNet ($runnerOptions + $restoreOptions)
+ $label = if ($implementation -eq 'baseline') { $BaselineCommit } else { 'member-provenance-current' }
+ $previousBenchmarkTiering = $env:DOTNET_TieredCompilation
+ try {
+ $env:DOTNET_TieredCompilation = '0'
+ Invoke-CheckedDotNet @((Join-Path $outputRoot "bin/$implementation/Release/net10.0/SharpTS.MemberBenchmark.dll"),
+ (Join-Path $outputRoot "$implementation.json"), $label)
+ } finally { $env:DOTNET_TieredCompilation = $previousBenchmarkTiering }
+}
+$before = Get-Content -LiteralPath (Join-Path $outputRoot 'baseline.json') -Raw | ConvertFrom-Json
+$after = Get-Content -LiteralPath (Join-Path $outputRoot 'current.json') -Raw | ConvertFrom-Json
+foreach ($report in $after.reports) {
+ $original = $before.reports | Where-Object fixture -eq $report.fixture
+ foreach ($category in @('diagnostics', 'types', 'lexical')) {
+ if ($original.fingerprints.$category -ne $report.fingerprints.$category) {
+ throw "$category mismatch: $($report.fixture), capture=$($report.memberCapture)"
+ }
+ }
+}
+Write-Output "Matching diagnostics/types/lexical identities. Reports: $outputRoot/baseline.json and current.json"
diff --git a/benchmarks/member-references/MemberReferences.csproj b/benchmarks/member-references/MemberReferences.csproj
new file mode 100644
index 000000000..7b3df6e4e
--- /dev/null
+++ b/benchmarks/member-references/MemberReferences.csproj
@@ -0,0 +1,14 @@
+
+
+ net10.0
+ Exe
+ SharpTS.Tests
+ enable
+ enable
+ latest
+ false
+
+
+
+
+
diff --git a/benchmarks/member-references/Program.cs b/benchmarks/member-references/Program.cs
new file mode 100644
index 000000000..2e3c402d0
--- /dev/null
+++ b/benchmarks/member-references/Program.cs
@@ -0,0 +1,164 @@
+using System.Diagnostics;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using OmniSharp.Extensions.LanguageServer.Protocol;
+using OmniSharp.Extensions.LanguageServer.Protocol.Models;
+using SharpTS.LanguageServer;
+using SharpTS.LanguageServer.Services;
+
+const int coldSamples = 11, warmSamples = 31;
+string outputRoot = Path.GetFullPath(args[0]);
+Directory.CreateDirectory(outputRoot);
+var reports = new List();
+foreach (bool multi in new[] { false, true })
+{
+ Fixture fixture = Fixture.Create(Path.Combine(outputRoot, "fixtures", multi ? "multi-project" : "small"), multi);
+ using (var prewarm = new Session(fixture))
+ for (int i = 0; i < 3; i++) await prewarm.Query();
+ var cold = new List();
+ for (int i = 0; i < coldSamples; i++)
+ {
+ using var session = new Session(fixture);
+ cold.Add(await Measure(session));
+ }
+ var warm = new List();
+ AnalysisStatistics retained;
+ using (var session = new Session(fixture))
+ {
+ await session.Query();
+ for (int i = 0; i < warmSamples; i++) warm.Add(await Measure(session));
+ retained = session.Statistics;
+ }
+ if (warm.Any(sample => sample.Checks != 0)) throw new InvalidOperationException("Unchanged warm query rechecked.");
+ if (cold.Concat(warm).Select(sample => sample.Fingerprint).Distinct().Count() != 1)
+ throw new InvalidOperationException("Cold/warm references differ.");
+ reports.Add(new
+ {
+ name = multi ? "multi-project" : "small", fixture.SourceFiles, fixture.Projects,
+ expectedReferencesIncludingDeclaration = fixture.ExpectedReferences,
+ cold = Summarize(cold), warm = Summarize(warm),
+ retainedSnapshots = retained.RetainedSnapshots, estimatedRetainedBytes = retained.EstimatedRetainedBytes,
+ });
+}
+var report = new
+{
+ implementation = args.Length > 2 ? args[2] : "current", timestampUtc = DateTime.UtcNow,
+ runtime = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription,
+ os = System.Runtime.InteropServices.RuntimeInformation.OSDescription,
+ architecture = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString(),
+ coldSamples, warmSamples,
+ methodology = "Member references including declarations across initialized configured roots. Empty service cache per cold sample; unchanged capture/shared cache for warm samples. Three prewarm queries; JIT/OS file caches warm. noLib/types=[] fixtures, no CLR metadata provider or stdio. Full currentness validation included. Process-wide allocations across asynchronous workers; source-relative result fingerprints. Cache bytes are estimates, not retained heap or a process limit.",
+ reports,
+};
+string output = Path.Combine(outputRoot, args[1]);
+await File.WriteAllTextAsync(output, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }));
+Console.WriteLine(output);
+
+static async Task Measure(Session session)
+{
+ long checks = session.Statistics.Checks, before = GC.GetTotalAllocatedBytes(precise: true);
+ var watch = Stopwatch.StartNew();
+ var result = await session.Query();
+ watch.Stop();
+ long allocated = GC.GetTotalAllocatedBytes(precise: true) - before;
+ var proof = session.Fingerprint(result);
+ return new(watch.Elapsed.TotalMilliseconds, allocated,
+ session.Statistics.Checks - checks, proof.Fingerprint, proof.PayloadBytes);
+}
+
+static object Summarize(List samples) => new
+{
+ medianMilliseconds = samples.Select(sample => sample.Milliseconds).Order().ElementAt(samples.Count / 2),
+ p90Milliseconds = samples.Select(sample => sample.Milliseconds).Order().ElementAt((int)Math.Ceiling(samples.Count * .9) - 1),
+ medianAllocatedBytes = samples.Select(sample => sample.AllocatedBytes).Order().ElementAt(samples.Count / 2),
+ checksPerQuery = samples.Select(sample => sample.Checks).Distinct().Order().ToArray(),
+ resultFingerprint = samples[0].Fingerprint, relativeLocationPayloadBytes = samples[0].PayloadBytes, samples,
+};
+
+record Sample(double Milliseconds, long AllocatedBytes, long Checks, string Fingerprint, int PayloadBytes);
+
+sealed class Session : IDisposable
+{
+ readonly Fixture _fixture;
+ readonly SemanticAnalysisService _analysis;
+ readonly ReferenceService _references;
+ public Session(Fixture fixture)
+ {
+ _fixture = fixture;
+ var workspace = new NavigationWorkspaceContext();
+ workspace.Initialize(new InitializeParams { RootUri = new Uri(fixture.Root).AbsoluteUri });
+ _analysis = new(workspace);
+ _references = new(_analysis);
+ }
+ public AnalysisStatistics Statistics => _analysis.Statistics;
+ public async Task Query()
+ {
+ var result = await _references.FindReferenceResultAsync(_fixture.Capture, _fixture.Position,
+ includeDeclaration: true, workspaceRoots: [_fixture.Root], cancellationToken: CancellationToken.None);
+ if (!result.IsCurrent() || !result.IsComplete || result.IsRenameEligible ||
+ result.Locations.Count != _fixture.ExpectedReferences)
+ throw new InvalidOperationException($"Unexpected member references: count={result.Locations.Count}, complete={result.IsComplete}, rename={result.IsRenameEligible}.");
+ return result;
+ }
+ public (string Fingerprint, int PayloadBytes) Fingerprint(NavigationReferenceResult result)
+ {
+ var relative = result.Locations.Select(location => new
+ {
+ path = Path.GetRelativePath(_fixture.Root, location.Uri.GetFileSystemPath()).Replace('\\', '/'),
+ location.Range.Start.Line, location.Range.Start.Character,
+ endLine = location.Range.End.Line, endCharacter = location.Range.End.Character,
+ }).ToArray();
+ byte[] payload = JsonSerializer.SerializeToUtf8Bytes(relative);
+ return (Convert.ToHexString(SHA256.HashData(payload)), payload.Length);
+ }
+ public void Dispose() { _references.Dispose(); _analysis.Dispose(); }
+}
+
+sealed record Fixture(string Root, DocumentRequestSnapshot Capture, Position Position,
+ int SourceFiles, int Projects, int ExpectedReferences)
+{
+ public static Fixture Create(string root, bool multi)
+ {
+ void Write(string relative, string source)
+ {
+ string path = Path.Combine(root, relative);
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ if (!File.Exists(path) || File.ReadAllText(path) != source) File.WriteAllText(path, source);
+ }
+ const string options = "\"compilerOptions\":{\"noLib\":true,\"types\":[]}";
+ string path, text;
+ if (!multi)
+ {
+ text = "class Box { value = 1; }\nconst box = new Box();\nconst first = box.value;\nconst second = box.value;\n";
+ path = Path.Combine(root, "main.ts");
+ Write("main.ts", text);
+ Write("tsconfig.json", "{" + options + ",\"files\":[\"main.ts\"]}");
+ }
+ else
+ {
+ Write("tsconfig.json", "{" + options + ",\"files\":[],\"references\":[{\"path\":\"packages/lib\"},{\"path\":\"apps/one\"},{\"path\":\"apps/two\"}]}");
+ Write("packages/lib/src/box.ts", "export class Box { value = 1; }\n");
+ Write("packages/lib/tsconfig.json", "{" + options + ",\"include\":[\"src/**/*.ts\"]}");
+ foreach (string app in new[] { "one", "two" })
+ {
+ Write($"apps/{app}/tsconfig.json", "{" + options + ",\"include\":[\"src/**/*.ts\"],\"references\":[{\"path\":\"../../packages/lib\"}]}");
+ for (int file = 0; file < 12; file++)
+ Write($"apps/{app}/src/importer{file}.ts", "import { Box } from '../../../packages/lib/src/box';\nconst box = new Box();\nconst result = box.value;\n" +
+ string.Join("\n", Enumerable.Range(0, 30).Select(index => $"export function compute{index}(value: number): number {{ return value + result; }}")) + "\n");
+ }
+ path = Path.Combine(root, "apps/one/src/importer0.ts");
+ text = File.ReadAllText(path);
+ }
+ path = Path.GetFullPath(path);
+ var store = new DocumentStore();
+ string uri = new Uri(path).AbsoluteUri;
+ if (!store.Open(uri, text, 1) || !store.TryCapture(uri, out var capture))
+ throw new InvalidOperationException("Cannot capture benchmark document.");
+ if (Directory.GetFiles(root, "*.ts", SearchOption.AllDirectories).Length != (multi ? 25 : 1) ||
+ Directory.GetFiles(root, "tsconfig.json", SearchOption.AllDirectories).Length != (multi ? 4 : 1))
+ throw new InvalidOperationException("Fixture directory has unexpected source/config files; use a fresh OutputDirectory.");
+ return new(root, capture, new Position(2, (multi ? "const result = box." : "const first = box.").Length + 1),
+ multi ? 25 : 1, multi ? 4 : 1, multi ? 25 : 3);
+ }
+}
diff --git a/benchmarks/member-references/README.md b/benchmarks/member-references/README.md
new file mode 100644
index 000000000..3474a69d9
--- /dev/null
+++ b/benchmarks/member-references/README.md
@@ -0,0 +1,43 @@
+# Source class member reference benchmark
+
+Optional replay for #1980, outside the solution and ordinary tests. It uses the `SharpTS.Tests`
+friend assembly name for internal service counters; it adds no server dependency.
+
+```powershell
+./benchmarks/member-references/run.ps1
+# Offline restore using an already populated package cache:
+./benchmarks/member-references/run.ps1 -PackageCache C:/Users/you/.nuget/packages
+```
+
+Each query includes declarations and discovers configured projects within initialized roots.
+The small fixture has one source file and three references. The larger fixture has 25 source
+files, four configurations, project references and closed reverse importers; its 25 references
+must be deduplicated across separately checked projects. Both set `noLib: true` and `types: []`
+and exclude CLR metadata. A complete result must remain ineligible for member rename.
+
+After three prewarm queries, 11 cache-cold samples use fresh service instances and 31 warm
+samples reuse one unchanged capture. JIT and OS file caches are warm. Samples include snapshot
+acquisition, discovery, location projection and final physical-input currentness validation.
+The runner refuses changed cold/warm result fingerprints or any warm checker invocation.
+Captures come through `DocumentStore`, including the URI-to-physical-path spelling used by LSP.
+Fingerprinting and payload serialization occur after the measured time/allocation window.
+Unexpected source/config files in reused fixture directories cause refusal; use a fresh
+`OutputDirectory` to isolate another run.
+
+Reports contain median/p90 latency, process-wide allocations across asynchronous workers,
+whole-graph check counts, relative-location payload bytes and estimated cache retention.
+Payload bytes describe compact benchmark JSON with relative paths rather than an LSP envelope.
+Cache estimates do not measure retained heap, peak working set or a process memory limit.
+This measures service behavior; stdio transport and editor UI are covered separately.
+
+The [recorded Windows ARM64/.NET 10.0.12 run](results-2026-10-07.json) used the exact
+service source hashes in the report and returned matching cold/warm fingerprints:
+
+| Fixture | Cold median / p90 | Warm median / p90 | Checks cold / warm | Estimated cached bytes |
+| --- | ---: | ---: | ---: | ---: |
+| One source, one configuration | 12.97 / 13.87 ms | 1.44 / 1.51 ms | 1 / 0 | 26,772 |
+| 25 sources, four configurations | 258.62 / 269.04 ms | 8.52 / 9.60 ms | 3 / 0 | 6,891,530 |
+
+The empty solution configuration contributes discovery without another anchor component check.
+These are single-machine measurements, not a comparison to a previous member-reference service.
+The report also contains allocation medians and relative-location payload sizes (220/2,278 bytes).
diff --git a/benchmarks/member-references/results-2026-10-07.json b/benchmarks/member-references/results-2026-10-07.json
new file mode 100644
index 000000000..b7283d124
--- /dev/null
+++ b/benchmarks/member-references/results-2026-10-07.json
@@ -0,0 +1,83 @@
+{
+ "implementation": "1225760c+working-tree",
+ "timestampUtc": "2026-10-08T04:02:40.1281814Z",
+ "runtime": ".NET 10.0.12",
+ "os": "Microsoft Windows 10.0.26220",
+ "architecture": "Arm64",
+ "coldSamples": 11,
+ "warmSamples": 31,
+ "methodology": "Member references including declarations across initialized configured roots. Empty service cache per cold sample; unchanged capture/shared cache for warm samples. Three prewarm queries; JIT/OS file caches warm. noLib/types=[] fixtures, no CLR metadata provider or stdio. Full currentness validation included. Process-wide allocations across asynchronous workers; source-relative result fingerprints. Cache bytes are estimates, not retained heap or a process limit.",
+ "reports": [
+ {
+ "name": "small",
+ "SourceFiles": 1,
+ "Projects": 1,
+ "expectedReferencesIncludingDeclaration": 3,
+ "cold": {
+ "medianMilliseconds": 12.9707,
+ "p90Milliseconds": 13.8676,
+ "medianAllocatedBytes": 543992,
+ "checksPerQuery": [
+ 1
+ ],
+ "resultFingerprint": "6E6B28DECF72868274794B49A7E3D450012DF0C942281AD77992929B706D3D0D",
+ "relativeLocationPayloadBytes": 220
+ },
+ "warm": {
+ "medianMilliseconds": 1.4418,
+ "p90Milliseconds": 1.5067,
+ "medianAllocatedBytes": 52784,
+ "checksPerQuery": [
+ 0
+ ],
+ "resultFingerprint": "6E6B28DECF72868274794B49A7E3D450012DF0C942281AD77992929B706D3D0D",
+ "relativeLocationPayloadBytes": 220
+ },
+ "retainedSnapshots": 2,
+ "estimatedRetainedBytes": 26772
+ },
+ {
+ "name": "multi-project",
+ "SourceFiles": 25,
+ "Projects": 4,
+ "expectedReferencesIncludingDeclaration": 25,
+ "cold": {
+ "medianMilliseconds": 258.6224,
+ "p90Milliseconds": 269.0443,
+ "medianAllocatedBytes": 63770128,
+ "checksPerQuery": [
+ 3
+ ],
+ "resultFingerprint": "1898CA65D458FFCE736229AF4AAAA723F5EC65CB489A964A72D7EADD548641ED",
+ "relativeLocationPayloadBytes": 2278
+ },
+ "warm": {
+ "medianMilliseconds": 8.5221,
+ "p90Milliseconds": 9.5988,
+ "medianAllocatedBytes": 1660496,
+ "checksPerQuery": [
+ 0
+ ],
+ "resultFingerprint": "1898CA65D458FFCE736229AF4AAAA723F5EC65CB489A964A72D7EADD548641ED",
+ "relativeLocationPayloadBytes": 2278
+ },
+ "retainedSnapshots": 2,
+ "estimatedRetainedBytes": 6891530
+ }
+ ],
+ "sourceFiles": [
+ {
+ "sha256": "21067A6CF9DFDFF9DC6E2AD64012624518C632C41B9B7D287FCE72592698A001",
+ "path": "src/SharpTS.LanguageServer/Services/ReferenceService.cs"
+ },
+ {
+ "sha256": "52838CB6BFBBBD74062255DF984998339E6A12E27FA19CB1B8F2595380854A85",
+ "path": "src/SharpTS.LanguageServer/Services/ReferenceService.Members.cs"
+ },
+ {
+ "sha256": "1FF444A95BB080858510E814C7365155ED3A8F8A7D728968D162DB6ECA7D2016",
+ "path": "src/SharpTS.LanguageServer/Services/NavigationModelBuilder.cs"
+ }
+ ],
+ "verificationOutsideMeasurement": "Result fingerprinting and payload serialization follow the timed/allocation window. Captures use DocumentStore."
+}
diff --git a/benchmarks/member-references/run.ps1 b/benchmarks/member-references/run.ps1
new file mode 100644
index 000000000..051b251fc
--- /dev/null
+++ b/benchmarks/member-references/run.ps1
@@ -0,0 +1,20 @@
+param([string]$OutputDirectory = '', [string]$PackageCache = '')
+$ErrorActionPreference = 'Stop'
+$taskRepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
+if (!$OutputDirectory) { $OutputDirectory = Join-Path $taskRepoRoot 'artifacts/member-reference-benchmark' }
+$taskOutputRoot = [IO.Path]::GetFullPath($OutputDirectory)
+New-Item -ItemType Directory -Path $taskOutputRoot -Force | Out-Null
+$taskRestoreOptions = @('-p:NuGetAudit=false', '-p:MinVerVersionOverride=0.0.0-benchmark')
+if ($PackageCache) {
+ $taskEmptyFeed = Join-Path $taskOutputRoot 'empty-feed'
+ New-Item -ItemType Directory -Path $taskEmptyFeed -Force | Out-Null
+ $taskRestoreOptions += @("-p:RestorePackagesPath=$([IO.Path]::GetFullPath($PackageCache))", '--source', $taskEmptyFeed)
+}
+& dotnet build (Join-Path $taskRepoRoot 'src/SharpTS.LanguageServer/SharpTS.LanguageServer.csproj') -c Release -v minimal @taskRestoreOptions
+if ($LASTEXITCODE) { throw 'Language server build failed.' }
+$taskLibraryDirectory = Join-Path $taskRepoRoot 'src/SharpTS.LanguageServer/bin/Release/net10.0'
+& dotnet build (Join-Path $PSScriptRoot 'MemberReferences.csproj') -c Release -v minimal "-p:LibraryDirectory=$taskLibraryDirectory" "-p:BaseOutputPath=$taskOutputRoot/bin/" "-p:BaseIntermediateOutputPath=$taskOutputRoot/obj/" @taskRestoreOptions
+if ($LASTEXITCODE) { throw 'Benchmark build failed.' }
+$taskRevision = & git -C $taskRepoRoot rev-parse --short HEAD
+& dotnet (Join-Path $taskOutputRoot 'bin/Release/net10.0/SharpTS.Tests.dll') $taskOutputRoot results.json "$taskRevision+working-tree"
+if ($LASTEXITCODE) { throw 'Member reference benchmark failed.' }
diff --git a/docs/README.md b/docs/README.md
index deba36db9..07e573785 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -50,6 +50,8 @@ known gaps, use [STATUS.md](../STATUS.md).
- [Debug interpreted TypeScript](debugging-interpreter.md)
- [Debug compiled TypeScript](debugging-typescript.md)
- [Language server](language-server.md)
+- [Standalone editor release notes](editor-release-notes.md)
+- [Formatting TypeScript and TSX](formatting.md)
- [External benchmark harness](../benchmarks/cross-runtime/README.md)
- [Managed microbenchmarks](../benchmarks/micro/SharpTS.Microbenchmarks/README.md)
- [Public performance snapshots](../benchmarks/snapshots/README.md)
diff --git a/docs/editor-member-provenance.md b/docs/editor-member-provenance.md
new file mode 100644
index 000000000..7fdd60ea3
--- /dev/null
+++ b/docs/editor-member-provenance.md
@@ -0,0 +1,116 @@
+# Source class member provenance
+
+`TypeChecker.WithMemberProvenance()` records source class origins separately from
+lexical bindings. The language server freezes both indexes into one checked
+analysis. This is metadata for editor consumers; it does not change diagnostics,
+type results or runtime behavior. Ordinary compiler checking leaves capture off.
+
+Member uses also require parser-owned authoritative source views from
+`WithSourceDocument(document).WithEditorSyntax()`. The language-server builder
+enables both. Enabling checker capture alone can retain direct declarations,
+but use occurrences remain unavailable without those exact written-owner views.
+
+One captured document and reference-equal class AST define a canonical source
+owner. Checker declaration IDs observed during preparation and body checking are
+aliases of that owner. The first observed ID is its stable canonical ID; integer
+equality alone does not establish source identity. A new checker generation
+produces new member identities. Frozen copies remain independent of later checks.
+
+Suppressed preparatory source checks do not publish use operations. Before a source document's
+authoritative pass, use facts are cleared while its declaration groups and nominal aliases
+remain registered. A body skipped by the final check therefore cannot leave a preparatory
+member reference behind. Trusted declaration files retain their authoritative collection pass.
+
+| Source or operation | Provenance contract |
+| --- | --- |
+| Identifier-named source fields, methods, auto-accessors and parameter-properties | Direct source declarations form canonical groups, with separate instance/static facets. User `.d.ts` classes and class expressions participate. |
+| Legal overloads and getter/setter pairs | One canonical group retains every source declaration. Invalid mixed declarations do not merge merely because their names collide. |
+| Inherited and generic members | The checker-selected declaring class supplies the origin. An override has its own origin; supported instantiated/inherited generic lookups retain the base origin. |
+| Named reads, writes, calls, `this` and `super` | Actual successful checker-selected lookup branches record separate operation facts. A failed call can retain a proven member read without claiming call proof. |
+| Repeated checker passes | A later check replaces the same AST owner's operation fact. Distinct owners or operations combine conservatively; earlier speculative failures cannot poison an authoritative recheck. |
+| Union members | Every required constituent must be supported and agree on one canonical origin. Different origins remain explicit candidates and yield no authoritative definition/reference result. |
+| Nullable optional access and unvisited intersection constituents | Unproven branches keep the aggregate unavailable. The index does not run additional checker lookups to infer an identity. |
+| ECMAScript private reads/writes/calls | The checker-selected lexical owner and nominal receiver must prove the correct instance/static private facet. Source-owner aliases preserve that proof across checker passes. |
+| `#field in candidate` | A successful check names the exact lexical brand, including a checker-valid `object` or `any` candidate. The probed object's nominal class is not the brand identity. |
+| Updates | A checked operand such as `c.value++` can retain a proven read. Existing update checking does not establish a separate source-member write. |
+| Compound/logical member assignments and literal-key class indexing | Existing paths such as `c.value += 1`, `c.value ||= 1` and `c['value']` can return `any` without selecting a named class member. Those paths remain unavailable. Exact key ranges are necessary, but do not themselves prove an origin. |
+| Property-flow narrowings | The current early return supplies a narrowed type without a new member selection. Carrying the previous origin would require provenance to follow the same narrowing-context invalidation, scope and merge rules; no global name/path guess is used. |
+| Existing `any` fallbacks | Generic self-typed method parameters and some forward class-typed `.ts` parameters currently become `any`. Capture on/off preserve that behavior and refuse a fabricated origin; supported local construction and declaration-order-safe types still participate. |
+| Structural/interface/record/mapped/index-signature, dynamic/unknown/error, CLR and built-in domains | No source class identity is fabricated. Quoted/numeric/arbitrary computed declarations are outside this first source-member scope. |
+| Generated syntax | An in-range token is insufficient. The exact occurrence owner needs an authoritative written member-name view, so generated parameter-property prologue writes cannot impersonate source uses. |
+
+`MemberResolution` retains candidates and lookup completeness separately.
+`IsResolved` requires one candidate and complete required evidence. Frozen
+definition and known-reference queries require that resolved identity. Project
+graph completeness is a separate analysis fact.
+
+Full-mode go-to-definition consumes these proven origins after lexical binding selection.
+It returns each canonical group's exact source declaration-name locations, deduplicated and
+ordered by path and span. Targets use their own captured source document, including dirty
+dependencies. Unrelated graph incompleteness does not erase a proven target; stale results
+are discarded.
+
+Full-mode references select lexical bindings first, then require one proven member identity.
+They join separately checked projects using canonical declaration path/full name spans plus
+member name, kind and instance/static/private facet. Checker IDs and AST references are local
+to their own generation. Fresh canonical groups supply only their resolved frozen occurrences;
+locations use each target document's own line index and deduplicate full spans.
+
+The initialized workspace's configured discovery graph must be complete before any new member
+reference locations are collected. Missing roots/configurations/project references return an
+empty member result without leaking selected-document uses. Output stays within initialized
+roots. Dirty overlays, closed reverse importers and project references share the same checked
+workspace inputs and final validation.
+
+A complete graph is distinct from complete semantic evidence for every possible use. A project
+whose checker never registered the selected member contributes no invented occurrences; the
+frozen index retains unavailable evidence independently. Structural compatibility, runtime
+implementors and spelling do not establish a class-member reference.
+
+Every new member identity currently denies rename. A parameter-property's
+constructor-local lexical binding also denies rename because editing that facet
+alone would miss property uses. Ordinary lexical bindings retain their existing
+complete-graph rename behavior. The separate private-domain query grants authority only after
+its complete local audit; ordinary member reference results never grant that permission.
+
+`FrozenMemberIndex.GetPrivateRenameDomain` audits the selected class's exact original private
+tokens against resolved canonical identities and its entered source body. Same-spelled private
+names in unrelated classes stay separate. Unvisited/recovered/unresolved source, nested class
+owners/subtrees and unsupported declaration shapes refuse the whole domain. The query supplies
+only exact selected tokens and the proven owner's private declarations for collision checks;
+it performs no checker lookup or spelling-based occurrence search. Private compound/logical
+assignment and prefix/postfix updates currently fail ordinary parsing and remain refusal cases.
+
+The LSP private operation requires a known captured version and negotiated versioned document
+changes. Prepare covers the whole `#old` token with that placeholder; rename accepts bare or
+prefixed names and emits one `#`. The local domain is independent of workspace discovery, while
+source/dependency/metadata currentness still guards publication.
+
+`PrivateRenameDomainTests` and `PrivateRenameHandlerTests` add 67 cases for whole-token ownership,
+authoritative body entry, legal keyword/BMP names, unsupported domains, negotiated edits, exact
+captured versions and source/configuration/metadata races. Applied field/method/static renames
+reparse and rebind, preserving execution in both interpreter and compiled modes. All 3,542
+affected Release tests passed. `scripts/test-private-rename.mjs` verifies seven real stdio client
+configurations, LF/CRLF edits, fresh diagnostics after application, workspace-independent local
+proof, cancellation peers and retained lexical rename with versioned-edit support on/off/missing.
+
+`MemberIndexTests`, `SourceMemberDeclarationTests`, `SourceMemberOccurrenceTests`
+and `MemberAnalysisTests` verify identities, operation evidence, source ownership,
+refusals and snapshot lifetime. The member definition service, snapshot and handler tests
+verify final locations, lexical precedence, capture reuse and cancellation/currentness.
+`RenameServiceTests` independently protects the parameter-property gate.
+
+The #1980 extension adds 42 service/handler cases and passed 3,475 affected tests. Real stdio
+verification covers two configured projects, a project reference, a closed reverse importer,
+exact UTF-16/CRLF ranges, dirty owner versions and close restoration, incomplete discovery,
+member rename refusal and cancellation. Shared-analysis/navigation and signature-help stdio
+regressions also passed. Imported generic/path-alias fixtures caught and verify a Windows
+drive-letter casing fix in configured component selection, without weakening their assertions.
+
+The optional [member-reference benchmark](../benchmarks/member-references/README.md) records
+cold/warm service latency, allocations, result payload sizes, cache estimates and checker counts
+for one-source and 25-source/four-configuration fixtures. Both retain zero warm whole-graph checks.
+
+The optional [member provenance benchmark](../benchmarks/member-provenance/README.md)
+compares ordinary and enabled checking against the preceding commit, including
+allocation, retained publication size and semantic identity fingerprints.
diff --git a/docs/editor-release-notes.md b/docs/editor-release-notes.md
new file mode 100644
index 000000000..6d1840150
--- /dev/null
+++ b/docs/editor-release-notes.md
@@ -0,0 +1,58 @@
+# Standalone editor support — unreleased
+
+[Epic #1390](https://github.com/nickna/SharpTS/issues/1390) adds a useful standalone
+editing workflow for SharpTS's supported TypeScript and TSX. Use
+`sharpts-lsp --language-features full --diagnostics all` as the sole language server.
+The shipped VS Code extension keeps its coexistence preset; another TypeScript
+service owns ordinary language features there. See the [language-server guide](language-server.md)
+and [formatter recipes](formatting.md) for exact configuration and tested clients.
+
+Full mode now provides ordinary semantic hover, lexical and checked receiver
+completion, complete and unfinished call/constructor signatures, source
+definitions, checker-bound source member references, lexical rename and a narrow
+safe ECMAScript-private rename. Completion replaces the full written identifier;
+signature labels respect client UTF-16 capabilities. Private rename requires a
+completely checked, supported class domain and versioned document edits. It edits
+the entire `#name` token and refuses unknown, stale, nested or incompletely checked
+domains. Public/structural, TypeScript-private/protected and parameter-property
+member rename remain unavailable.
+
+These consumers share immutable checked components and exact captured document,
+configuration, resolution and CLR metadata inputs. Repeated unchanged requests
+reuse analysis; dirty dependencies and physical changes invalidate it. Caret-local
+completion/signature recovery can build a fresh component and remains bounded.
+This is whole-component checking, with no incremental AST or checker reuse.
+The completed-graph cache defaults to eight entries and an estimated 64 MiB,
+including at most four checked cursor entries. Active leases, in-flight work,
+runtime/CLR state and estimator error are outside that estimate; it is not a
+process-memory cap. Unknown or ambiguous facts produce unavailable results rather
+than invented bindings or overload winners.
+
+Both modes retain SharpTS interop hover/completion/signatures and quick fixes.
+`interop-only --diagnostics sharpts-only` leaves ordinary features to the other
+TypeScript service. Feature mode is fixed during initialization; diagnostic mode
+can change live. Explicit `diagnostics=all` requests full diagnostics in either
+mode without enabling ordinary providers in coexistence mode. The raw CLI defaults
+remain `full` and `sharpts-only`; standalone recipes explicitly opt into all
+diagnostics. Neither mode supplies formatting, inlay hints, semantic tokens or
+folding. Pinned external Prettier owns formatting.
+
+The [aggregate contract command](../scripts/test-editor-contract.ps1) exercises six
+stdio suites, formatter compatibility and an exact locally installed tool shim.
+The full CI route runs it on Ubuntu and Windows and retains failure evidence.
+Native [Neovim](../scripts/editor-smoke/neovim-editing/README.md) and
+[Helix](../scripts/editor-smoke/helix-editing/README.md) checks exercise real editing,
+native edit application and saves. The [shipping VS Code smoke](../scripts/editor-smoke/vscode-shipping/README.md)
+activates the actual extension alongside built-in TypeScript and Prettier. Neovim's
+two-server run verifies ordinary ownership and native combined CLR hover; Helix's
+documented first-provider routing does not promise merged results. Older formatter
+bridge runs establish formatter compatibility only.
+
+Reproducible [workflow measurements](../benchmarks/editor-workflow/README.md)
+separate startup, cache-cold and warm requests, changed versions/carets, payloads,
+allocations, actual checker counts, cache estimates and managed-heap observations.
+Existing-path comparisons use their exact historical baselines; newly available
+features report absolute cost. Timing comparisons do not gate CI.
+
+The supported/refused matrix and evidence links in the language-server guide are
+the release contract. This work does not promise TypeScript language-service parity.
diff --git a/docs/editor-semantic-queries.md b/docs/editor-semantic-queries.md
new file mode 100644
index 000000000..ad983f7f8
--- /dev/null
+++ b/docs/editor-semantic-queries.md
@@ -0,0 +1,191 @@
+# Editor semantic queries
+
+`TypeChecker.WithEditorMetadata()` opts into source-bound facts for editor features. It also
+enables source member provenance. Ordinary compile and interpreter checks leave both disabled.
+`NavigationModelBuilder` publishes `EditorFacts.Freeze()` in the shared `AnalysisSnapshot`;
+request handlers consume that completed capture instead of retaining or running a checker.
+
+The mutable builder records facts at actual declaration, expression, scope and invocation
+decisions. Publication replaces raw types with bounded TypeScript presentations and copies
+binding/member identities and collections. The frozen query index retains captured source
+documents and AST owners, without checker, resolver, type environment, mutable class types or
+inference callbacks. Its document/owner reference keys and generation prevent a query from
+combining old and edited captures. A checker reset starts a new generation.
+
+Suppressed preparatory source checks do not publish query/use facts. Each source document's
+query facts and non-declaration member uses are cleared before its authoritative pass, while
+exact declaration identities, nominal aliases and original signature maps survive. Trusted
+declaration-file collection remains its authoritative pass. Visibility requires actual body
+entry and refuses unvisited contexts or skipped same-context locals; syntax only supplies
+these vetoes, never a replacement scope or inferred type.
+
+## Available facts
+
+| Query | Source of authority | Explicit limits |
+| --- | --- | --- |
+| Declaration type | Final checked declaration, after widening/contextual checking | A failed declaration is unavailable; an initializer's narrower type does not replace its declared type. |
+| Occurrence type | Successful checking of the exact written expression | Generated/recovered expressions cannot establish an authoritative type. An attempted failure replaces an earlier successful fact. |
+| Annotation type use | Actual resolution of the exact written type node | Unknown-name fallbacks do not prove a resolved type. Query capture does not force lazy aliases or run another resolution pass. |
+| Visible bindings | Checker-owned environments linked to captured source scopes | Local spellings retain canonical imported identity and value/type facets. Unavailable or TDZ locals still shadow outer names. |
+| Receiver members | Already checked receiver type and actual lexical class context | Class accessibility, static/instance/private facets and known generic substitutions are retained. Structural members have no invented class origin. |
+| Invocation candidates | Resolved callable/constructable type, before argument validation | Public overloads exclude the implementation signature. Unresolved callee types remain unavailable. |
+| Selected invocation | Actual successful checker overload/instantiation decision | Argument/inference errors, holes, recovery, incomplete candidate sets and aggregate callable unions cannot prove a single winner. |
+
+Known source transformations have explicit original-owner bridges for contextual callbacks and
+fresh object literals. Within a checked graph, source identity is never recovered by matching a
+name, type, return type or source range. Workspace navigation separately joins proven canonical
+declarations across fresh graphs by full path/span and member facet. Implicit constructors are
+marked as implicit and point to the exact source
+class, rather than claiming a constructor declaration exists.
+
+The query layer does not perform another inference pass or evaluate a type merely for display.
+Exact public callable surfaces also govern nested declaration, occurrence and receiver
+presentations when the checker has flattened an overload family into its implementation.
+Annotation eligibility is learned at the existing resolution decision and propagated through
+exact source declaration/binding identities. Unknown-name fallbacks cannot become proven
+declaration, occurrence, visible-local or receiver-member types merely because the checker
+represents them as `any`. Alias expansion retains that eligibility through its existing cache,
+without publishing substituted facts for the alias definition's original type nodes.
+Direct assertion initializers retain the same eligibility through grouping and non-null
+wrappers. This is a source-owner check; arbitrary expression/dataflow propagation still follows
+the compiler's existing checked type facts.
+Unused lazy type aliases retain their spelling and binding identity with an unavailable resolved
+type. Generic union projection currently omits a name when any branch carries a generic
+substitution map, even if those maps match, and marks the result partial. Existing unsupported
+checker paths remain unavailable, including dynamic `any` receivers,
+static `super` member lookup and function/record `new` fallbacks without a checked constructor
+decision. Instance `super` candidates follow the checker's method-only domain. Source navigation
+still requires the narrower [member provenance contract](editor-member-provenance.md).
+
+Unannotated instance fields retain the checker's declared `any` type, including ordinary classes
+and class expressions. Checking a numeric initializer does not publish an inferred instance-field
+type; an explicit field annotation provides that precision. Static declaration fields have a
+separate inference path. The query layer preserves these actual checked results. A generic
+method annotation that the checker cannot resolve remains
+unavailable for editor presentation. Runtime constructor checking can replace
+a public overload set with its implementation function. Editor capture keeps the exact original
+public candidates separately, but that implementation-only validation cannot prove selection
+of a public overload. Ambient constructor overloads retain the actual chosen signature.
+Accessor storage that merges static/instance declarations with the same spelling cannot supply
+an unambiguous declaration type. Computed/quoted/numeric member declaration slots remain outside
+this source-name domain. A catch body that the checker does not visit supplies no invented facts.
+Legacy private/abstract annotations whose structured nodes are not attached to the checker's
+resolution slot can still prove their declaration/member eligibility through that exact owner.
+The detached annotation spelling itself has no invented resolved type-use fact.
+
+## Bounds and presentation
+
+Receiver publication retains at most 256 names. Class/interface hierarchy and constituent walks
+stop at 32, with reference-identity cycle checks and cancellation. Receiver type graph composition
+has a separate node budget; an unsupported or exhausted projection is partial/truncated.
+Invocation publication retains at most 32 candidates. Truncation cannot establish selection.
+
+`EditorTypeRenderer` does not call `TypeInfo.ToString()`. Default presentation limits are depth 8,
+256 type nodes, 4,096 UTF-16 characters and 32 public signatures/type parameters. A 33rd draft
+type parameter preserves the truncation marker without copying an unbounded list. Limits are clamped; recursive
+types and large lists terminate with explicit truncation. Signature parameter ranges refer to
+the rendered label. Missing/inferred analysis displays `unavailable`; an actual checked `any`
+displays `any`. Known types outside the renderer's supported forms also display `unavailable`,
+including several weak-collection, generator and host builtin forms. Enum values use `E`, since
+the checker has no separate enum constructor/instance representation. Generic method type parameters shadow class substitutions. Presentations can
+be partial even when the checker has a type, so consumers must inspect availability/truncation.
+
+Visibility refusal applies to the whole query when any enclosing scope has a skipped local;
+it does not remove that name and expose a potentially shadowed outer binding.
+
+The shared cache includes the query index's estimated payload. This estimate approximates
+collection/object overhead and is separate from measured retained heap; it is not a process
+memory limit. Full-mode hover, completion and signature help consume these values. Capture
+itself does not change advertised capabilities.
+
+## Completion consumption
+
+`SemanticCompletionService` projects visible bindings and receiver members directly from
+the frozen query index. It filters unavailable facts, invalid identifier spellings and
+type-only namespace exports in value access. Results are sorted and deduplicated, contain at
+most 256 items and use plain text edits replacing the exact whole identifier. Details are
+limited to 1,024 UTF-16 characters. Structural candidates retain no invented class identity.
+
+Completed source syntax supplies the context for ordinary queries. An unfinished member
+operator can request a fresh cursor-specific checked program through `SemanticAnalysisService`;
+the [syntax contract](editor-syntax-coverage.md) describes that isolated parse and its limits.
+A parse-only artifact never supplies receiver proof. A missing, stale or foreign seed uses
+the configured cold path, and recovered output is revalidated before returning. Lexical
+completion refuses documents with parse diagnostics because a discarded declaration could
+hide a same-spelled outer binding. It does not create a second scope engine.
+Unused lazy aliases therefore supply no typed completion item. Existing checker call results
+also remain authoritative: an annotated source-class function can currently yield checked
+`any` at its call site despite its declared signature, so that receiver has no member results.
+
+## Signature-help consumption
+
+`SemanticSignatureHelpService` joins the innermost parser invocation to its exact frozen
+invocation owner. It projects available public candidates in original ordinal order, with
+checked instantiations when available. Unresolved callable annotations are tracked at existing
+type/signature construction decisions by reference identity, including anonymous functions,
+returned callable types, alias cache reuse and existing substitution paths. Publication
+refuses those candidates and clears a selected signature if its proof is unavailable.
+Explicit invocation type arguments retain their own resolution proof; an invalid use cannot
+taint a shared original signature. A proven formal signature can be displayed when its
+instantiation is unavailable. Instantiated parameter names come only from the exact original
+signature and a matching parameter count, without mutating the compiler's type objects.
+Proof covers captured callable owners and signature-owned annotations. Shared outer generic
+interface constraint/default metadata is not separately tracked, so this is not a claim of
+complete generic-constraint provenance.
+
+Argument indices count only the parser's recorded top-level commas. Each rendered signature
+maps rest arguments and supplies exact UTF-16 parameter label slices. Recovered/hole-bearing,
+incomplete and candidates-only facts never establish selection. A top-level active parameter
+can follow the first visible signature for protocol presentation while `activeSignature`
+remains absent. Signature help does not run another overload resolver or infer selection from
+parameter counts, labels or return types. Unfinished argument lists use the checked cursor
+bridge; a cached parse-only artifact is never checked again.
+An inner invocation that the checker never visits because an enclosing call fails early
+remains unavailable; the service does not substitute the outer call's signatures.
+
+## Verification and measured cost
+
+The #1979 signature-help extension adds 115 cases and passed 3,433 affected tests, the
+TypeScript smoke profile (32 corpus cases/17 harness tests) and 9 targeted Test262 cases.
+Four real stdio clients passed full/interop-only isolation, optional capability negotiation,
+unfinished/nested argument lists, exact UTF-16 label slices, dirty dependency/close behavior
+and cancellation. Completion, hover and shared-analysis stdio regressions also passed.
+
+The #1978 completion extension adds 99 focused cases and passed 3,318 affected tests, the
+TypeScript smoke profile (32 corpus cases/17 harness tests) and 9 targeted Test262 cases.
+Four real stdio completion clients passed full/interop-only mode isolation, incomplete
+member access, applied UTF-16/CRLF edits, restricted item kinds and dirty dependency/close
+behavior. Hover and shared-analysis stdio regressions also passed. Cursor tests cover
+configuration/resolution fidelity, fresh AST ownership, seed validation, coalescing,
+cancellation, stale inputs, cache limits and independent metadata leases.
+
+The #1977 hover extension passed 3,219 affected tests, the same TypeScript smoke profile
+(32 corpus cases/17 harness tests), and 9 targeted Test262 tests. Real stdio hover clients
+passed in full/interop-only modes with Markdown/plain text, including imported dirty types,
+exact CRLF/UTF-16 ranges and retained CLR/decorator hover. Existing shared-analysis stdio
+navigation, rename and cancellation contracts also passed.
+
+The #1976 foundation passed 97 focused tests, 3,128 affected tests, the TypeScript smoke
+profile (32 corpus cases across 17 harness tests) and 9 targeted Test262 tests. Selected
+shared runtime parity passed 204/204 in compiled/interpreted modes. Existing full-mode
+and interop-only stdio contracts also passed.
+
+The [replayable benchmark](../benchmarks/editor-semantics/README.md) compares the exact
+`1cfcc46a` baseline with #1976 ordinary, member-only and editor capture on identical
+freshly parsed graphs. Diagnostics, public types and lexical identity fingerprints match
+across all variants. The recorded single-machine Windows ARM64/.NET 10.0.12 run found
+no ordinary median regression above 10%; ordinary allocations increased by 1.31%/1.15%
+for the small/25-module fixtures.
+
+| Added editor capture over member-only capture | Small | 25 modules |
+| --- | ---: | ---: |
+| Cold check and publication, median | +0.6664 ms | +44.4984 ms |
+| Allocated bytes per check | +504,298 | +20,393,888 |
+| Retained heap after full GC | +69,912 bytes | +2,830,328 bytes |
+
+Allocation churn, retained heap and index estimates are separate measurements. Immutable
+read batches took 0.00975 ms for 21 queries and 2.45651 ms for 525 queries (medians), without
+parsing or checking. See the [compact results](../benchmarks/editor-semantics/results-2026-10-07.json)
+for p90 values and counts. These warmed-process measurements exclude LSP transport,
+handler rendering and snapshot acquisition; #1981 still needs interactive measurements
+with snapshot reuse. They do not establish a general speedup or a process heap limit.
diff --git a/docs/editor-syntax-coverage.md b/docs/editor-syntax-coverage.md
new file mode 100644
index 000000000..7eea891e1
--- /dev/null
+++ b/docs/editor-syntax-coverage.md
@@ -0,0 +1,88 @@
+# Editor syntax provenance
+
+`Parser.WithEditorSyntax()` captures an immutable `EditorSyntaxIndex` owned by the
+`SourceDocument`. Ordinary parsing keeps its existing statement/debugger spans
+and does not allocate an editor index. The editor index adds views over existing
+AST objects; it does not add position fields to records or change their equality.
+
+| Consumer syntax | Captured source views and contexts | Limits |
+| --- | --- | --- |
+| Lexical names | Written variable references, assignment/update targets and declaration/parameter/type names | Generated temporary names have no written-name location. |
+| Members | Receiver, operator, name token and whole access; optional/private flags; literal string/number index keys | Dynamic computed indexes retain their expression range, without inventing a static member identity. |
+| Calls and construction | Whole expression, callee, opening/closing parentheses and only top-level argument commas; optional/new flags | `new C` has no fabricated argument-list delimiter. Tagged templates keep expression provenance without posing as parenthesized calls. |
+| Expression nesting | Intermediate chain nodes, binary/logical/conditional/comma/unary/wrapper expressions, literals, arrays/objects, templates and updates | Destructuring and parser replacements use source-equivalent provenance where an existing source construct survives lowering. Later compiler transforms are outside this index. |
+| Functions and classes | Declaration/function-expression/arrow whole and header views; body, parameter list, parameter names and class member names | Method header views begin at the parsed name and exclude preceding modifiers. A computed method header begins at `(`; its computed key has a separate expression range. Overload/ambient signatures have no invented body. Generated runtime wrapper functions are not user declarations. |
+| Blocks | Written brace/body extent and nested statement spans | A parser-created statement sequence is not an extra written block. |
+| Written types | Existing type-node roots and nested forms, annotations, qualified names, type parameters and grouping views | Parenthesized types can share one semantic type object and have separate grouping views. Implicit `any` has no written type range. |
+| TSX | Original element/attribute/type-argument ranges and embedded source expressions, preserved through JSX lowering | Factory/runtime imports and generated props/children scaffolding do not acquire written declaration or call-delimiter identities. |
+| Unfinished expressions | Private cursor-local artifact for `receiver.`, `receiver?.`, `super.`, `f(`, `f(a,`, `f(a, ,`, `new C(` and bounded nested variants | Missing names/arguments/closers are explicit recovery, not authoritative rename or definition locations. Arbitrary-error recovery remains unavailable. |
+
+All ranges are half-open UTF-16 offsets into exactly one document. Syntax lookup
+uses binary search over flat interval arrays, then selects the narrowest supported
+view. Ties use start position, kind, role and original recording order. Invocation
+and member caret contexts also accept the boundary immediately before a close or
+after an unfinished token; that caret convention does not widen written ranges.
+Only `Written`, `Grouping` and `SourceEquivalent` views can supply authoritative
+source matches. `Recovered`, `Synthetic` and `Implicit` views cannot.
+
+Publication filters every view against objects reachable from the final parser
+AST, including auxiliary parameter/property/type objects. It also prunes opt-in
+span entries for abandoned speculative objects. Split `>>`/`>>>` generic closers
+track the exact consumed character, and speculative checkpoints restore both
+the cursor and the original token. Copy callbacks are detached at completion or
+cancellation, and the mutable collector does not survive publication.
+
+For parser branches whose existing semantic AST deliberately keeps only a type
+spelling, editor-only owner attachments retain the already-parsed type syntax.
+These edges follow source-equivalent owner replacements and disappear if their
+owner is abandoned. They preserve editor annotation views without changing the
+checker-visible AST or implicit-type behavior.
+
+`Parser.ParseForEditor` uses unchanged source text with a fresh document/token
+stream and explicit query/policy inputs. A repaired gap must actually contain
+the caret, between consumed source and the next real token; an unrelated broken
+statement before or after the caret is not repaired. Repairs have count/nesting
+bounds and are disabled inside comments and literals. Missing-argument comma
+lookahead stops beyond the remaining repair budget and observes cancellation.
+`super.` keeps its written keyword as receiver proof while its missing name and
+whole recovered access remain non-authoritative. Bare `this.#` still fails the
+ordinary lexer, and arbitrary broken enclosing braces remain unsupported.
+
+The language server keeps two distinct uses of this parser. Its existing syntax
+artifact cache remains parse-only: it never checks a cached artifact or adds
+bindings to it. Semantic cursor analysis instead creates a fresh checked graph.
+An opt-in `ModuleResolver.EditorParseTarget` replaces only the exact canonical
+target module's parse with a fresh `ParseForEditor` result. The resolver's
+original lexer still supplies reference directives and JSX pragmas, and normal
+configuration, imports, path aliases, declaration preference and program/library
+processing continue. Other source modules use fresh ordinary parses; embedded
+read-only library declarations keep their existing shared path. No published
+base AST or cached syntax artifact is checked again.
+
+A seed must belong to the same analysis service, match the exact originating
+request stamp and target path, and remain current. Its captured reads, probes
+and directory inventories are replayed before reconstructing configuration and
+resolution, with extra reads captured as needed. The build owns a fresh metadata
+view and validates both seed and combined inputs before publication. A missing,
+foreign or mismatched seed uses a fresh configured build; a configured cursor
+build refuses fallback if configuration or membership fails, or checking cannot
+produce a model.
+
+Checked cursor graphs share the ordinary admission slots, LRU and default
+64 MiB estimated payload budget, with at most four completed cursor entries.
+They acquire no ordinary document aliases. Cache keys include caret, query,
+policy and an opaque weak-table base identity that does not retain the base AST.
+Only the in-flight build retains a seed lease; a completed child does not own its
+seed. The separate
+parse-only cache stays bounded within the same combined byte budget.
+
+`EditorExpressionTests`, `EditorDeclarationSyntaxTests`, `EditorSyntaxIndexTests`
+and `EditorExpressionRecoveryTests` pin these contracts with exact-offset and
+query fixtures. `CursorRecoveryBoundaryTests` and `EditorParseTargetResolverTests`
+cover caret-gap limits and fresh target parsing through the normal module graph.
+Expression catalog classification and a separate concrete
+`TypeNode` catalog guard require an explicit provenance decision when those
+families grow; auxiliary owners have focused reachability tests because they
+are outside the expression/statement catalog. Existing source-span, type-node,
+debugger and navigation suites protect ordinary behavior. Replay parse time and
+allocation measurements with [the optional benchmark](../benchmarks/editor-syntax/README.md).
diff --git a/docs/formatting.md b/docs/formatting.md
new file mode 100644
index 000000000..2e67f98ba
--- /dev/null
+++ b/docs/formatting.md
@@ -0,0 +1,276 @@
+# Formatting TypeScript and TSX
+
+Use Prettier **3.9.9**, pinned in [tools/formatter-interop](../tools/formatter-interop/package.json),
+to format SharpTS source. The editor owns formatting in both `full` and `interop-only` language-server
+modes. `sharpts-lsp` does not advertise document, range or on-type formatting and does not require
+Node.js or Prettier to run.
+
+## Install and verify
+
+Use Node.js 22 or newer (the repository version is recorded in [`.node-version`](../.node-version)).
+From the repository root, install the exact development-tool versions in the lockfile and run the
+compatibility smoke check with PowerShell 7 and the repository's .NET SDK:
+
+```powershell
+npm ci --prefix tools/formatter-interop
+pwsh ./scripts/test-formatter-interop.ps1
+```
+
+The check formats representative TypeScript, declaration and TSX inputs, checks a second pass for
+idempotence, and passes original and formatted inputs through SharpTS with matching options. It
+also compares representative execution results and checks the server's formatting capabilities.
+This is an opt-in development check; ordinary .NET builds and the packaged language server do not
+install a formatter.
+
+For a separate SharpTS application, install the same version as a project development dependency:
+
+```powershell
+npm install --save-dev --save-exact prettier@3.9.9
+```
+
+Use that application's `node_modules/prettier` in place of `tools/formatter-interop/node_modules/prettier`
+in the editor recipes below, and commit its package lockfile.
+
+Keep project formatting choices in a `.prettierrc.json` or another
+[Prettier configuration file](https://prettier.io/docs/configuration). For example:
+
+```json
+{
+ "tabWidth": 2,
+ "semi": true,
+ "endOfLine": "lf"
+}
+```
+
+Use the installed CLI directly when diagnosing editor differences:
+
+```powershell
+node tools/formatter-interop/node_modules/prettier/bin/prettier.cjs --version
+node tools/formatter-interop/node_modules/prettier/bin/prettier.cjs --write path/to/app.ts
+```
+
+When sending a buffer through stdin, pass its real filename with `--stdin-filepath`, including its
+`.ts`, `.d.ts` or `.tsx` extension. That lets Prettier infer the TypeScript parser and discover the
+configuration for that file. A `.tsx` filename also selects TSX syntax. Do not use a fictitious
+`stdin.ts` filename or force a JavaScript parser. See the
+[Prettier CLI documentation](https://prettier.io/docs/cli#--stdin-filepath).
+
+## Select one formatting owner
+
+The same Prettier installation can serve both language-server arrangements:
+
+| Editing arrangement | SharpTS mode and diagnostics | Ordinary TypeScript features | Formatting |
+| --- | --- | --- | --- |
+| SharpTS as the sole server in Neovim or Helix | `full --diagnostics all` | SharpTS's supported navigation and interop features | External Prettier |
+| SharpTS alongside an existing TypeScript server | `interop-only --diagnostics sharpts-only` | The existing TypeScript server | External Prettier |
+| VS Code with the SharpTS extension | Extension's default `interop-only`; `sharpts.diagnostics: sharpts-only` | VS Code's TypeScript service | Prettier extension using the pinned module |
+
+Full mode's supported features are described in the [language-server guide](language-server.md);
+it does not provide every feature of TypeScript's language service. Disable other format-on-save
+hooks for these files when choosing Prettier. Import organization and lint fixes are separate
+actions and are not required for formatting.
+
+## VS Code
+
+Install **Prettier - Code formatter** (`esbenp.prettier-vscode`). In a checkout of this repository,
+merge the following into `.vscode/settings.json`:
+
+```json
+{
+ "prettier.prettierPath": "./tools/formatter-interop/node_modules/prettier",
+ "sharpts.diagnostics": "sharpts-only",
+ "[typescript]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.formatOnSave": true
+ },
+ "[typescriptreact]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.formatOnSave": true
+ }
+}
+```
+
+`prettier.prettierPath` names the module directory, not its CLI script. For another workspace,
+use the path to the same pinned module there. Keep the language settings in separate blocks.
+Use **Format Document With...** to confirm the selected owner and the **Prettier** output channel
+to check the resolved module/version. The extension uses the open document's filename, so no
+stdin override is needed. Local module loading requires a trusted workspace; an untrusted
+workspace can use the extension's bundled version instead. See the official
+[Prettier VS Code settings](https://github.com/prettier/prettier-vscode#settings).
+
+## Neovim
+
+For Neovim 0.11+, register SharpTS with the recipe in the
+[language-server guide](language-server.md#install-and-launch). Use `full --diagnostics all` when
+it is the sole server. When keeping an existing TypeScript client, use
+`interop-only --diagnostics sharpts-only` for SharpTS and keep that client's ordinary feature setup.
+
+The following `init.lua` example sends the unsaved buffer to the pinned CLI before saving. Replace
+the absolute CLI path with your checkout's path; forward slashes also work in Windows paths.
+`node` must be available on the editor's `PATH`. It runs the executable directly, without a shell.
+
+```lua
+local prettier_cli = "/absolute/path/to/SharpTS/tools/formatter-interop/node_modules/prettier/bin/prettier.cjs"
+
+vim.api.nvim_create_autocmd("BufWritePre", {
+ group = vim.api.nvim_create_augroup("sharpts_prettier", { clear = true }),
+ pattern = { "*.ts", "*.tsx" },
+ callback = function(ev)
+ local path = vim.api.nvim_buf_get_name(ev.buf)
+ if path == "" or vim.bo[ev.buf].buftype ~= "" then
+ return
+ end
+ local lines = vim.api.nvim_buf_get_lines(ev.buf, 0, -1, false)
+ local newline = vim.bo[ev.buf].fileformat == "dos" and "\r\n" or "\n"
+ local input = table.concat(lines, newline)
+ if vim.bo[ev.buf].endofline then
+ input = input .. newline
+ end
+ local tick = vim.api.nvim_buf_get_changedtick(ev.buf)
+ local ok, result = pcall(function()
+ return vim.system(
+ { "node", prettier_cli, "--stdin-filepath", path },
+ { stdin = input }
+ ):wait(5000)
+ end)
+ if not ok or result.code ~= 0 then
+ local detail = ok and result.stderr or tostring(result)
+ vim.notify("Prettier failed; saving unformatted: " .. (detail or ""),
+ vim.log.levels.WARN)
+ return
+ end
+ if not vim.api.nvim_buf_is_valid(ev.buf)
+ or vim.api.nvim_buf_get_changedtick(ev.buf) ~= tick then
+ vim.notify("Buffer changed; skipped stale formatting", vim.log.levels.WARN)
+ return
+ end
+ if result.stdout == input then
+ return
+ end
+ local output = result.stdout or ""
+ local fileformat = output:find("\r\n", 1, true) and "dos" or "unix"
+ output = output:gsub("\r\n", "\n")
+ local endofline = output:sub(-1) == "\n"
+ if endofline then
+ output = output:sub(1, -2)
+ end
+ local view = vim.fn.winsaveview()
+ vim.api.nvim_buf_set_lines(ev.buf, 0, -1, false,
+ vim.split(output, "\n", { plain = true }))
+ vim.bo[ev.buf].fileformat = fileformat
+ vim.bo[ev.buf].endofline = endofline
+ vim.fn.winrestview(view)
+ end,
+})
+```
+
+This uses Neovim's documented [direct process API](https://neovim.io/doc/user/lua/#vim.system())
+and a bounded synchronous save hook. A formatting failure keeps and saves the original buffer.
+Do not add a second TypeScript formatting autocmd calling
+`vim.lsp.buf.format()`. If another formatter plugin already owns these files, configure that
+plugin to invoke this pinned Prettier CLI with the buffer filename instead of installing this hook.
+
+## Helix
+
+Merge this into your project's `.helix/languages.toml`. Replace both absolute CLI paths and ensure
+`node` and `sharpts-lsp` are available on `PATH`:
+
+```toml
+[language-server.sharpts]
+command = "sharpts-lsp"
+args = ["--language-features", "full", "--diagnostics", "all"]
+
+[[language]]
+name = "typescript"
+language-servers = ["sharpts"]
+auto-format = true
+formatter = { command = "node", args = ["/absolute/path/to/SharpTS/tools/formatter-interop/node_modules/prettier/bin/prettier.cjs", "--stdin-filepath", "%{buffer_name}"] }
+
+[[language]]
+name = "tsx"
+language-servers = ["sharpts"]
+auto-format = true
+formatter = { command = "node", args = ["/absolute/path/to/SharpTS/tools/formatter-interop/node_modules/prettier/bin/prettier.cjs", "--stdin-filepath", "%{buffer_name}"] }
+```
+
+Helix passes the buffer through stdin; `%{buffer_name}` supplies the filename. The external
+`formatter` takes precedence over LSP formatting, and `auto-format` enables it on save. See
+[Helix language configuration](https://docs.helix-editor.com/languages.html).
+
+For coexistence, change SharpTS's arguments to
+`["--language-features", "interop-only", "--diagnostics", "sharpts-only"]` and replace each
+`language-servers` list with:
+
+```toml
+language-servers = [
+ { name = "typescript-language-server", except-features = ["format"] },
+ "sharpts",
+]
+```
+
+This uses Helix's built-in TypeScript-server definition and your existing installation of that
+server. Completions, code actions and diagnostics from both servers are merged. Hover and signature
+help use the first server advertising the feature: this order prioritizes ordinary TypeScript
+results. Put `sharpts` first if you prefer SharpTS-specific hover/signature results; ordinary results
+for those requests may then be unavailable. Server ordering does not merge these two providers.
+
+## Recorded shipping and native editing smoke
+
+The October 7, 2026 Windows ARM64-host editing checks use the current Release server from source
+`a20b07d7` and pinned Prettier 3.9.9. VS Code and Neovim use Node.js 25.5.0; the Helix run uses
+repository-pinned Node.js 22.23.2 ARM64 with the x64 editor build:
+
+| Client | Mode and diagnostics | Actual client verification |
+| --- | --- | --- |
+| VS Code 1.134.0 with installed SharpTS VSIX 0.2.0 and prettier-vscode 12.4.0 | Shipping `interop-only --diagnostics sharpts-only` defaults | Built-in TypeScript navigation remains active; real shipping extension activation supplies CLR decorator/member hover before and after two dirty saves. Extension logs identify the pinned local formatter. [Smoke and evidence](../scripts/editor-smoke/vscode-shipping/README.md). |
+| Neovim 0.12.5, SharpTS alone | `full --diagnostics all` | Native hover/navigation, applied completion and lexical/versioned private edits, unfinished signatures, dirty-dependency diagnostics and close restoration; actual `.ts`/`.tsx` saves use the Lua hook above and fresh post-save requests. [Native editing smoke](../scripts/editor-smoke/neovim-editing/README.md). |
+| Neovim 0.12.5 with real TypeScript adapter 6.0.1 / TypeScript 6.0.3 | SharpTS `interop-only --diagnostics sharpts-only` | Ordinary navigation/diagnostics belong to TypeScript; CLR content reaches Neovim's native merged hover. One external save hook formats both extensions while both servers remain attached. [Exact tool lock](../tools/editor-interop/package-lock.json), [compact evidence](../scripts/editor-smoke/neovim-editing/last-verified.json). |
+| Helix 25.07.1 (`a05c151b`), SharpTS alone | `full --diagnostics all` | Actual native completion insertion, lexical/private edit application, unfinished signatures, navigation and dirty diagnostics. `.ts`, `.tsx` and dependency saves match Prettier bytes with fresh hovers before and after formatting. [Native smoke](../scripts/editor-smoke/helix-editing/README.md), [compact evidence](../scripts/editor-smoke/helix-editing/last-verified.json). |
+
+These tests exercise actual native edit application and the shipping VSIX. They do not claim every
+editor/plugin combination. Neovim's recorded default capabilities refuse private rename; a separate
+explicit `documentChanges` opt-in proves native application of the versioned edit. SharpTS never
+advertises formatting in either mode. The TypeScript adapter does advertise formatting, but the
+documented coexistence setup selects only the external Prettier save hook.
+
+The aggregate `pwsh ./scripts/test-editor-contract.ps1` runs the formatter compatibility evidence,
+six real-stdio feature suites and isolated installed-tool checks. The same aggregate gates Ubuntu
+and Windows CI; optional native editor runs retain their own versioned evidence.
+
+## Earlier formatter-only smoke
+
+On October 6, 2026, the following checks passed on Windows ARM64 with Node.js 25.5.0, Prettier
+3.9.9 and a Release build of `SharpTS.LanguageServer`:
+
+| Client | Mode | Execution and result |
+| --- | --- | --- |
+| VS Code 1.134.0, ARM64; prettier-vscode 12.4.0 | `interop-only` | Two dirty format-on-save cycles matched the pinned local module exactly; fresh SharpTS CLR hover worked before and after each save. The extension log confirmed Prettier 3.9.9 loaded from the requested module directory. |
+| Neovim 0.12.5, ARM64 | `full`, `interop-only` | Headless editor using the exact Lua save hook above and native LSP client. Dirty `.ts`/`.tsx` saves matched Prettier byte for byte; fresh CLR hover worked before and after formatting; a second dirty save produced identical formatted output. |
+| Helix 25.07.1 (`a05c151b`), x64 | `full`, `interop-only` | Interactive terminal session using the external formatter above. Dirty `.ts`/`.tsx` saves matched Prettier byte for byte; correlated LSP requests resolved CLR hover before and after each formatted save. |
+
+All three clients verified that SharpTS did not supply formatting. Reproduction instructions are in the
+[VS Code smoke](../scripts/editor-smoke/vscode/README.md),
+[Neovim smoke](../scripts/editor-smoke/neovim/README.md) and
+[Helix smoke](../scripts/editor-smoke/helix/README.md) guides. These runs attached SharpTS in each
+mode. The Neovim and Helix sessions did not attach a second TypeScript server or test third-party
+formatter plugins. The VS Code smoke used the installed Prettier extension and a test-only SharpTS
+hover bridge; it did not exercise the shipping SharpTS extension's activation or packaging.
+
+## Troubleshooting and compatibility limits
+
+- Run `npm ci --prefix tools/formatter-interop` if the pinned module cannot be loaded. Invoke its
+ CLI with `--version` and check that the editor resolves that same installation.
+- Confirm the buffer has its real filename and correct `.ts`/`.tsx` extension. If configuration
+ seems wrong, use `--find-config-path path/to/file.ts` with the pinned CLI and check project
+ overrides and ignore files.
+- Disable duplicate save hooks or formatting providers if a file changes twice. Selecting
+ `interop-only` removes duplicate SharpTS navigation; it does not select a formatter in the editor.
+- A formatter parser error leaves source unchanged. Correct incomplete syntax or disable
+ format-on-save while editing it; do not remove supported SharpTS constructs to make formatting run.
+- The smoke fixture set is a compatibility sample, not a promise about every combination of syntax,
+ plugins and configuration. Preserve a failing supported example and rerun the smoke command when
+ upgrading the pinned formatter. An unsupported combination needs a verified workaround or an
+ explicit compatibility issue.
+
+The separate compatibility command checks syntax and behavior beyond these small editor fixtures.
+Recheck editor integration when changing client, formatter or save-hook versions.
diff --git a/docs/language-server.md b/docs/language-server.md
index 9e3b3f991..dbceef189 100644
--- a/docs/language-server.md
+++ b/docs/language-server.md
@@ -3,7 +3,12 @@
`sharpts-lsp` is a standard language server over stdio. It provides SharpTS-specific .NET interop
diagnostics, hover, completion, signature help, and quick fixes in every mode. Its full mode also
provides document symbols, definition, references, and completeness-gated rename for standalone
-editors.
+editors, plus semantic hover, lexical/member completion and signature help for ordinary TypeScript.
+
+Formatting is supplied by the editor and an external formatter. Neither language-feature mode
+advertises document, range or on-type formatting. See [Formatting TypeScript and TSX](formatting.md)
+for the pinned Prettier workflow, format-on-save recipes and formatter ownership when another
+TypeScript server is active.
## Install and launch
@@ -13,13 +18,15 @@ Install the separately packaged tool:
dotnet tool install --global SharpTS.LanguageServer
```
-The default is appropriate for an editor where SharpTS is the only TypeScript language server:
+When SharpTS is the only TypeScript language server, enable both ordinary editor features and
+full parser/type-checker diagnostics explicitly:
```bash
-sharpts-lsp
+sharpts-lsp --language-features full --diagnostics all
```
-The equivalent explicit launch is:
+The CLI defaults are independently `full` language features and `sharpts-only` diagnostics.
+Launching without flags is equivalent to:
```bash
sharpts-lsp --language-features full --diagnostics sharpts-only
@@ -27,16 +34,16 @@ sharpts-lsp --language-features full --diagnostics sharpts-only
Configure any LSP client with:
-- command: `sharpts-lsp`
+- command: `sharpts-lsp --language-features full --diagnostics all` for sole-server editing
- file types: TypeScript and TSX
- transport: stdio
- project root: the nearest `tsconfig.json`, `sharpts.json`, or workspace root
-For example, Neovim 0.11 can register it as:
+For example, Neovim 0.11+ can register it as:
```lua
vim.lsp.config.sharpts = {
- cmd = { "sharpts-lsp", "--language-features", "full" },
+ cmd = { "sharpts-lsp", "--language-features", "full", "--diagnostics", "all" },
filetypes = { "typescript", "typescriptreact" },
root_markers = { "tsconfig.json", "sharpts.json", ".git" },
}
@@ -48,7 +55,7 @@ Helix can launch the same server with:
```toml
[language-server.sharpts]
command = "sharpts-lsp"
-args = ["--language-features", "full"]
+args = ["--language-features", "full", "--diagnostics", "all"]
```
Then add `sharpts` to the `language-servers` list for the `typescript` and `tsx` language entries.
@@ -61,7 +68,7 @@ VS Code's SharpTS extension starts the bundled server in `interop-only` mode bec
built-in `tsserver` already owns ordinary TypeScript navigation:
```bash
-sharpts-lsp --language-features interop-only
+sharpts-lsp --language-features interop-only --diagnostics sharpts-only
```
This mode still advertises the features unique to SharpTS:
@@ -95,6 +102,35 @@ Clients may change this live with `workspace/didChangeConfiguration`:
```
VS Code exposes the same value as `sharpts.diagnostics`.
+Diagnostics selection is independent of language-feature mode and can change live. Language-feature
+mode is fixed at initialization. For example, `interop-only --diagnostics all` requests full
+SharpTS diagnostics while retaining interop-only editor providers; it can duplicate another
+TypeScript server's diagnostics.
+
+## Verified editor arrangements
+
+The reproducible editor smokes use explicit presets and record versions, capabilities, source,
+server hashes and logs. They exercise the actual clients and shipping extension:
+
+| Client and arrangement | SharpTS preset | Verified behavior and trace |
+| --- | --- | --- |
+| VS Code 1.134.0, installed SharpTS VSIX 0.2.0, built-in TypeScript extension 10.0.0 with TypeScript 6.0.3 | `interop-only --diagnostics sharpts-only` (shipping defaults) | Actual extension activation; ordinary imported definition; CLR decorator/member hover before and after two dirty saves; Prettier extension 12.4.0 loading local Prettier 3.9.9. [Shipping smoke](../scripts/editor-smoke/vscode-shipping/README.md), [compact evidence](../scripts/editor-smoke/vscode-shipping/last-verified.json). |
+| Neovim 0.12.5, SharpTS alone | `full --diagnostics all` | Eight native-client scenarios: hover UI, exact UTF-16 member navigation, applied completion/lexical/private edits, unfinished signatures, dirty dependency diagnostics and close restoration, TS/TSX saves. [Native smoke](../scripts/editor-smoke/neovim-editing/README.md), [compact evidence](../scripts/editor-smoke/neovim-editing/last-verified.json). |
+| Neovim 0.12.5, TypeScript adapter 6.0.1 with TypeScript 6.0.3 plus SharpTS | `interop-only --diagnostics sharpts-only` | One ordinary navigation/diagnostic owner, unique CLR content in native merged hover, and one external formatter. The adapter and backend are [exactly locked](../tools/editor-interop/package-lock.json); the real backend version notification is checked. [Compact evidence](../scripts/editor-smoke/neovim-editing/last-verified.json). |
+| Helix 25.07.1 (`a05c151b`), SharpTS alone | `full --diagnostics all` | Native completion insertion, lexical and versioned private edit application, unfinished signature help, definitions/references, dirty ordinary diagnostics and final clean results. `.ts`, `.tsx` and dependency saves match pinned Prettier with fresh before/after hovers. [Native smoke](../scripts/editor-smoke/helix-editing/README.md), [compact evidence](../scripts/editor-smoke/helix-editing/last-verified.json). |
+
+These recorded executions use a Windows ARM64 host and source base `a20b07d7` plus the #1981
+working-tree changes. All three final runs use the same server binary, including the shared
+token-helper refactor; each compact report records its hash. Helix is the x64
+editor build, and its formatter uses the repository-pinned Node 22.23.2 ARM64. Native default Neovim
+capabilities omit `documentChanges`, so private rename is refused. A separate explicit capability
+opt-in verifies versioned edit application; existing lexical rename works with either capability
+setting. General property rename and TypeScript language-service parity are not claimed.
+
+Neovim's native hover merges responses in the tested version. Helix's first-provider hover and
+signature routing has different behavior; see the [formatting/coexistence recipe](formatting.md#helix)
+before attaching two providers. The earlier formatter-only VS Code smoke used a test hover bridge;
+the shipping smoke above proves the installed extension's real activation and registrations.
## Project and .NET references
@@ -102,17 +138,271 @@ The server discovers workspace `tsconfig.json` files and their in-workspace proj
Use these launch options for CLR metadata:
```bash
-sharpts-lsp --project ./MyApp.csproj
-sharpts-lsp -r ./lib/MyInterop.dll -r ./lib/Another.dll
-sharpts-lsp --sdk-path /path/to/reference/assemblies
+sharpts-lsp --language-features full --diagnostics all --project ./MyApp.csproj
+sharpts-lsp --language-features full --diagnostics all -r ./lib/MyInterop.dll -r ./lib/Another.dll
+sharpts-lsp --language-features full --diagnostics all --sdk-path /path/to/reference/assemblies
```
A `sharpts.json` reference manifest found from the workspace root is also honored. Referenced
assembly outputs are reloaded safely when they change.
+Editor requests capture one CLR metadata generation and inspect assembly bytes without locking
+the original DLLs. Project, manifest, and assembly changes are revalidated before a result is
+returned. Package acquisition happens at startup; requests use already restored package assets.
+If a manifest adds a package that has not been restored, restore it and restart the server.
+An explicit `--sdk-path` is revalidated, while the automatically selected installed SDK/runtime
+is fixed for the server lifetime; restart after replacing that installation.
+
+Interop hover, completion, and diagnostics honor these custom references. General checker
+`dotnet:` synthesis still uses the existing runtime registry and may not resolve a custom
+reference's ordinary TypeScript symbols. The server does not guess navigation or rename results
+for unresolved symbols.
+
+## Source definitions
+
+Full mode resolves lexical names and supported source class members using the checked
+receiver's declaration identity. Inherited members target their base declaration; overrides
+target their own declaration. Getter/setter pairs and overload groups return their exact
+declaration-name locations. Source class expressions, user `.d.ts` classes, parameter-properties,
+auto-accessors and ECMAScript `#private` members participate where the checker proves the origin.
+
+Definition preserves lexical/type/namespace/label navigation and GUI navigation precedence.
+A known member target can be returned even when an unrelated project root is unavailable.
+Ambiguous unions, unproved receiver paths, structural members, dynamic keys and locationless
+CLR/builtin members supply no new class-member target. The
+[member provenance contract](editor-member-provenance.md) lists the exact supported operations
+and current checker limits. Definition support alone does not grant member rename permission.
+
+## Source class member references
+
+Full mode also finds proven uses of a selected source class member across configured projects
+inside the initialized workspace roots. Lexical/type/namespace bindings keep priority, including
+constructor-local parameter-property bindings. Inherited accesses belong to their base member;
+overrides and unrelated classes keep separate identities. Overload/accessor declaration groups
+honor `includeDeclaration`, and results use each target's own dirty or disk-backed source ranges.
+
+Member discovery requires initialized roots and a complete configured discovery graph. A missing
+root, unreadable configuration or unavailable project reference returns no new member result,
+including otherwise known uses in the selected document. Canonical declaration path and full name
+span connect independently checked projects; generation-local checker IDs are never compared.
+
+Graph completeness establishes which configured projects were discovered. It does not make the
+supported class-member proof domain exhaustive: structural/interface-typed accesses, unsupported
+checker branches and unvisited source supply no guessed references. A complete reference result
+does not authorize public/protected/TypeScript-private or parameter-property rename.
+
+The [member-reference benchmark](../benchmarks/member-references/README.md) records cache-cold /
+warm medians of 12.97 / 1.44 ms for one source and 258.62 / 8.52 ms for 25 sources across four
+configurations. Whole-graph checker calls were 1 / 0 and 3 / 0 respectively. These service-only
+Windows ARM64 measurements include input validation and exclude transport, editor UI and CLR
+metadata; cache-byte estimates are reported separately from managed-heap measurements.
+
+## Ordinary TypeScript hover
+
+Full mode shows checked declaration and occurrence types for ordinary names, parameters,
+functions, named annotations and supported source class members. It preserves flow-narrowed
+occurrence types and the exact public overload signatures retained by the checker. Private
+member names and `super` use their own semantic facts rather than the result of a surrounding
+call, assignment or brand check. Unresolved analysis supplies no semantic hover.
+
+Unknown annotation names also make their affected declarations and uses unavailable; an
+explicit checked `any` remains displayable. A qualified annotation's final name can show its
+resolved type. Earlier qualifiers require their own exact binding proof. Unused lazy aliases
+are not resolved just for display. Structural/dynamic member access remains outside the
+source class hover domain described by the semantic and member provenance contracts.
+
+Hover uses exact half-open UTF-16 source ranges and the client's preferred Markdown or plain
+text format. Existing GUI, decorator and CLR hover keeps priority. Full-mode CLR member usage
+and ordinary hover share the checked snapshot, including files that mix interop and ordinary
+TypeScript. Interop-only continues to serve SharpTS-specific hover without ordinary results.
+
+## Ordinary TypeScript completion
+
+Full mode offers visible names in supported value/type contexts and accessible members of
+checked class, namespace, interface and record receivers. Imports use their local alias;
+shadowing, TDZ visibility, static/instance access and private/protected accessibility follow
+the checker. Unknown or dynamic receivers supply no guessed members. Namespace value access
+omits type-only exports. Existing GUI and decorator completion keeps priority in both modes.
+
+Manual invocation and partial identifiers use plain text items with bounded type details and
+exact UTF-16 replacement edits, including the suffix after the cursor. Full mode adds `.` and
+`?` triggers to the existing SharpTS triggers. Item kinds honor the client's advertised set;
+semantic completion does not require snippets or an item-resolution request.
+
+Unfinished `receiver.` and `receiver?.` expressions use a fresh checked cursor graph from the
+captured program. Repeated requests reuse it. The bounded parser repairs missing expression
+parts; it does not repair arbitrary damaged enclosing scopes. Lexical completion refuses
+documents with parse errors, and comments, literals, nonmember dots and unsupported contexts
+return no semantic candidates. A bare `this.#` currently fails lexing; `this.` can still offer
+accessible private names. Auto-imports, quoted-key rewrites, keywords and ordinary JSX tag or
+attribute completion are outside this feature.
+
+Unused lazy type aliases have no available completion type until the checker resolves them.
+Some annotated source-class function returns currently check a call as `any` despite the
+function's declared return type; such receivers supply no members. Inferred class returns and
+checked structural returns can supply their proven members.
+
+## Ordinary TypeScript signature help
+
+Full mode shows captured public function, method and constructor candidates for the innermost
+supported call or `new` argument list. Callable aliases/interfaces and generic instantiations
+participate where the checker proves their signatures. Unknown annotation fallbacks cannot
+become fabricated `any` parameters; legitimate checked `any` remains displayable. Existing
+decorator signature help keeps priority in both modes.
+
+The parser's real delimiters and top-level commas determine the active argument, including
+nested expressions, templates, generic arguments and multiline calls. Each signature maps
+extra rest arguments to its rest parameter. The client receives parameter label offsets and
+per-signature active parameters only when it supports them. Truncated signatures have no
+guessed active parameter. The existing `(` and `,` triggers are retained.
+
+Unfinished `f(`, trailing argument gaps and `new C(` use the shared fresh cursor-analysis path.
+Recovered or erroneous calls can show proven candidates without claiming an overload winner.
+If an explicit type argument is unresolved, an available formal signature can remain visible
+without presenting an unproved instantiation. Instantiated signatures borrow parameter names
+only from their exact original signature when their parameter counts match.
+`activeSignature` is present only for an exact complete checker selection; a client's default
+first signature is presentation rather than a semantic decision. Runtime constructors whose
+checker validates only their implementation retain public candidates without a public winner.
+
+Completed literal arguments can show the enclosing signature. Comments, raw JSX text,
+unrelated callback bodies, malformed literals and expressions without a real argument-list
+delimiter supply no result. New CLR call help is limited to the checker's available candidates;
+this feature does not add a reflected overload resolver or execute calls.
+An unfinished grouping expression such as `f((1` is outside the parser's current recovery
+domain and supplies no signature help.
+If an enclosing call fails before the checker visits its arguments, an inner call has no
+signature facts and supplies no result, even when cursor recovery can parse it.
+
+## Shared analysis
+
+Hover, completion, signature help, definition, references, lexical rename, and full diagnostics share completed analyses for the
+same captured open buffers and project state. File notifications invalidate analyses promptly;
+clients without watching support still get physical dependency/configuration validation on every
+reuse. Creating a missing import, changing a closed file, or changing project membership causes
+a fresh check. Results that become stale during a request are discarded.
+
+The cache keeps at most eight completed entries and evicts the least recently used entries when
+their estimated source/semantic payload exceeds 64 MiB. This estimate excludes CLR assembly
+images and is not a limit on total process memory. Oversized analyses can serve active requests
+without entering the cache. Concurrent identical requests share work, and cancelling one request
+does not cancel another request's analysis. Interop-only with `sharpts-only` diagnostics keeps
+general analysis lazy; explicitly selecting `all` still requests full parser/checker diagnostics.
+
+Checked source documents also retain exact written syntax ranges for editor queries. Cursor
+recovery creates fresh tokens, AST and checker facts through the same configured module
+pipeline, including captured dirty dependencies. It never mutates a completed analysis or
+borrows a previous receiver type. Cursor analyses share admission, cancellation and the eight
+entry/64 MiB cache budget, with an additional maximum of four completed cursor entries. A
+configured program that cannot be reconstructed supplies no recovered result.
+
+The separate parse-only recovery cache retains at most 32 artifacts and 8 MiB within the shared
+64 MiB estimate. Those artifacts supply syntax context and cannot prove authoritative rename
+locations or semantic results on their own.
+
+Completed analyses also retain [bounded semantic query values](editor-semantic-queries.md):
+checked declaration/occurrence types, visible bindings, accessible receiver members and actual
+call/new candidate decisions. Ordinary hover, completion and signature help consume these
+values. They do not retain the checker's mutable environments.
+
+For a local protocol smoke test after a Release build, run:
+
+```bash
+node scripts/test-analysis-snapshots.mjs
+node scripts/test-semantic-hover.mjs
+node scripts/test-semantic-completion.mjs
+node scripts/test-semantic-signatures.mjs
+node scripts/test-member-references.mjs
+node scripts/test-private-rename.mjs
+```
+
+The test exercises real stdio navigation, source class member targets, watched closed-file
+changes, dirty overlays, reverse importer creation, rename, cancellation transport and
+interop-only capability isolation.
+The hover smoke runs full/interop-only clients with both markup formats, checking exact
+ordinary ranges, dirty imported types, close-to-disk restoration and retained CLR/decorator
+hover.
+The completion smoke checks full/interop-only triggers, ordinary and unfinished buffers,
+applied CRLF/UTF-16 edits, restricted item kinds, dirty dependencies and retained decorator
+completion.
+The signature smoke checks full/interop-only clients, label/parameter capability negotiation,
+unfinished and nested calls, public candidates versus selected signatures, dirty dependency
+restoration, and request cancellation with a surviving fresh request.
+The member-reference smoke covers independently configured projects, closed reverse importers,
+dirty source ranges, completeness refusal, rename denial and cancellation/currentness.
+
+For the aggregate protocol, formatter and packaged-tool check, use:
+
+```powershell
+pwsh ./scripts/test-editor-contract.ps1
+```
+
+The `editor-contract` CI job runs this same aggregate on Ubuntu and Windows. It records a unique
+artifact index and logs, runs the six protocol suites above, formats/checks representative inputs,
+then packs and installs the exact local language-server package into an isolated tool path and
+checks its installed shim in both modes. These deterministic checks gate CI. Native editor runs
+and controlled [editor-workflow measurements](../benchmarks/editor-workflow/README.md) are separate;
+no wall-clock performance threshold gates shared CI hosts.
+
+The [editor analysis benchmark](../benchmarks/editor-analysis/README.md) records a comparison with
+`df4589b7` on Windows Arm64/.NET 10.0.12 (2026-10-07 UTC). The sequence contains definition,
+workspace references, statement retrieval, and full diagnostics; JIT/filesystem caches are warm.
+
+| Fixture | Cache-cold median, before → shared | Warm median, before → shared | Checks, cold / warm before → shared |
+| --- | --- | --- | --- |
+| One source file | 18.03 → 16.65 ms | 13.65 → 5.38 ms | 4 / 3 → 1 / 0 |
+| 25 source files, four projects | 188.80 → 136.19 ms | 117.69 → 21.21 ms | 6 / 5 → 3 / 0 |
+
+Cold definitions add about 2.12 ms and 11.70 ms respectively for capture, immutable publication,
+and validation. Warm diagnostics also pay validation costs instead of trusting a stale cache.
+Direct document validation medians were 0.55 ms and 2.44 ms; workspace validation was 0.70 ms
+and 6.17 ms. Measured cache retention was about
+19 KiB and 3.42 MB respectively; these fixtures exclude CLR metadata and do not predict large
+project memory. The benchmark report includes allocations, percentiles, and measurement limits.
+
## Rename safety
-Full mode produces a rename edit only when the server has loaded every configured project root and
-project reference needed for the selected semantic binding. It deliberately refuses rename for an
-incomplete graph rather than returning a partial cross-file edit. General object/class
-property-member rename is not currently offered.
+Full mode produces a lexical rename edit only when the server has loaded every configured
+project root and project reference needed for the selected semantic binding. Incomplete graphs
+refuse cross-file rename. Public, protected, TypeScript `private` string-keyed and structural
+member rename remains unavailable.
+
+Constructor parameter-properties, such as `constructor(public value: number)`, also refuse rename
+from their declaration or constructor-local uses: those edits would need to coordinate the
+property name and its uses. Ordinary constructor parameters retain lexical rename support when
+the configured graph is complete.
+
+ECMAScript `#private` rename has a separate one-document proof. The selected class declaration or
+expression must have a complete checked private domain: every private token inside that exact
+source owner must have authoritative original syntax and the same lexical class owner. Supported
+operations include instance/static fields and methods, reads, plain writes, calls, closures and
+`#name in candidate`. A class inside an ordinary function can qualify. An owner nested in another
+class private environment or containing any nested class is refused.
+
+Private rename needs a known open-document version and client support for
+`workspace.workspaceEdit.documentChanges`. Prepare returns the whole `#old` range and placeholder;
+rename accepts `new` or `#new`, validates the private identifier and collisions, and emits exactly
+`#new` in versioned document changes. `#constructor`, malformed names and collisions with another
+private instance/static declaration are refused. Private keyword spellings such as `#new` and
+`#class` are legal when the parser accepts them. Existing lexical edits keep their client behavior.
+
+The private domain does not require complete workspace discovery, so an unrelated broken project
+does not block it. Unresolved private occurrences, unvisited source bodies, any target-document
+parse error and recovered source refuse the entire operation. The current parser rejects private
+compound/logical assignment and prefix/postfix updates; those buffers remain refused. Private
+accessor/auto-accessor declarations, multiple-declaration private method groups and nested private
+environments are outside this delivery. Final document, dependency and metadata validation can
+discard the whole edit if the captured state changes.
+
+Private-domain and handler verification adds 67 cases, including applied before/after behavior in
+both runtimes; the broader affected Release suite passed 3,542 tests. Reproduce the focused and
+real-protocol checks after building the Release server:
+
+```powershell
+dotnet test tests/SharpTS.Tests/SharpTS.Tests.csproj -c Release --filter "FullyQualifiedName~PrivateRename"
+node scripts/test-private-rename.mjs
+```
+
+The stdio check uses seven full/interop-only client configurations and verifies whole UTF-16
+tokens, captured document versions, LF/CRLF, applied fresh diagnostics, explicit refusals and
+ordinary lexical rename with versioned-edit support present, false or omitted.
diff --git a/scripts/editor-smoke/helix-editing/README.md b/scripts/editor-smoke/helix-editing/README.md
new file mode 100644
index 000000000..3227d4472
--- /dev/null
+++ b/scripts/editor-smoke/helix-editing/README.md
@@ -0,0 +1,69 @@
+# Native Helix standalone editing smoke
+
+This disposable workspace uses actual Helix LSP requests, native completion and
+rename application, dirty buffers, and its external format-on-save command. It
+does not implement an LSP client or add an editor provider. The server launches
+with `--language-features full --diagnostics all`; pinned Prettier owns formatting.
+
+Build the Release language server and install the formatter lockfile first. Run
+`node scripts/editor-smoke/helix-editing/prepare.mjs /absolute/path/to/hx` with the
+repository-pinned Node version. The command prints a fresh workspace and saves
+exact server/editor/module paths, versions, source commit, server hash, initial
+source and dirty stages in `test-config.json`. In that workspace launch:
+
+```powershell
+$env:HELIX_RUNTIME = '/absolute/path/to/helix/runtime'
+& '/absolute/path/to/hx' -c config.toml --log helix.log -vv main.ts
+```
+
+The fixture maps insert-mode Ctrl-K to `signature_help` and disables automatic
+completion/pairs. `gg` and `g|` select a one-based line and column. Use
+Space-K for hover, `gd` for definition, `gr` for references and Space-R for rename.
+Enter normal mode before each sequence; dismiss a hover/picker with Escape and
+allow each asynchronous response to arrive. Follow the [official keymap](https://docs.helix-editor.com/keymap.html).
+
+1. Hover `result` at `3gg7g|`. Select `add` at `3gg24g|` and use `gd`; Helix opens
+ `dependency.ts`. Rename `#secret` at `2gg3g|` to `#renamedSecret` with Space-R,
+ Ctrl-U, the new name and Enter. The native capability advertises versioned
+ document changes; verify both declaration and use change in the dirty buffer.
+2. In normal mode select the whole dependency with `%`, then run
+ `:pipe node stage.cjs dependency-dirty`. Do not save. The helper supplies exact
+ stdout to Helix's native pipe command; Helix sends its own versioned didChange.
+ It adds `extra` while preserving the private rename. This avoids terminal paste
+ and auto-indent differences and never communicates with the language server.
+3. `:open main.ts`, `%`, `:pipe node stage.cjs main-completion`. At `6gg8g|` use
+ `a`, Ctrl-X, wait for completion, then Ctrl-N to select `add`, Enter to accept,
+ and type `(`. Ctrl-K requests the unfinished-call signature. Completion must
+ contain `add`, the unsaved dependency's `extra`, and `value`.
+4. Escape, `%`, `:pipe node stage.cjs main-bad`; wait for ordinary type diagnostics.
+ Replace with `%` and `:pipe node stage.cjs main-valid`; wait for them to clear.
+ At `4gg7g|` rename `lexical` to `renamedLocal` with the native rename prompt.
+ Hover `result`, dismiss, `:write`, wait for current-version diagnostics, and
+ hover `result` again. Prettier must format the actual dirty save.
+5. `:open view.tsx`, `%`, `:pipe node stage.cjs view-dirty`. Hover `title` at
+ `3gg7g|`, dismiss, `:write`, wait for current-version diagnostics, then hover
+ again. For another dirty cycle, add trailing spaces to the title line with
+ `3ggA`, two spaces and Escape, then repeat hover/save/hover.
+6. Open the still-dirty dependency. At `5gg3g|`, `gr` must include the declaration
+ and the main-file use. Dismiss the picker; hover the renamed private field at
+ `2gg3g|`, save the dependency, and obtain a fresh hover. `:quit-all` shuts down.
+7. Run `node scripts/editor-smoke/helix-editing/verify.mjs /absolute/workspace`.
+ It correlates Helix's trace, reconstructs UTF-16 incremental document versions,
+ proves applied edits, dirty dependency lookup, formal unfinished signatures,
+ navigation, cleared ordinary diagnostics and exact pinned formatter bytes for
+ every final saved file. The result requires fresh post-save hovers and native
+ shutdown. It rejects LSP formatting and unsupported advertised providers.
+
+`trace.mjs /absolute/workspace` prints reconstructed buffers and recent requests
+for a failed manual run. Keep the native log, stage files, configuration and
+result together. Timing is not an acceptance criterion; a response may correctly
+be unavailable during a save/watch invalidation. Wait for the diagnostic for the
+current document version and make a fresh request before recording success.
+
+Recorded Windows ARM64 run: Helix 25.07.1 (`a05c151b`, x64), preparation/formatter
+Node 22.23.2 ARM64, Prettier 3.9.9, SharpTS source `a20b07d7`. The successful
+workspace is `artifacts/editor-contract/helix/run-ZAkEIw`; see `last-verified.json`.
+The final TS, TSX and dependency diagnostics were empty.
+This tests the sole-server recipe. Actual two-server ownership is covered by the
+[Neovim smoke](../neovim-editing/README.md); Helix chooses the first eligible server
+for hover/signatures and does not merge their results.
diff --git a/scripts/editor-smoke/helix-editing/last-verified.json b/scripts/editor-smoke/helix-editing/last-verified.json
new file mode 100644
index 000000000..20343fcc1
--- /dev/null
+++ b/scripts/editor-smoke/helix-editing/last-verified.json
@@ -0,0 +1,49 @@
+{
+ "completed": true,
+ "timestamp": "2026-10-08T05:03:15.012Z",
+ "editor": "helix 25.07.1 (a05c151b)",
+ "mode": "full",
+ "diagnostics": "all",
+ "commit": "a20b07d7259043dd06a33fae34a507b443c847d8",
+ "sourceNote": "Includes #1981 working-tree changes; the binary fingerprint identifies the server actually exercised.",
+ "serverSha256": "9dc0e3c3b4d401e833735179ee84185c460b9f2dcb2ae187f71f8e808627a40b",
+ "node": "v22.23.2",
+ "prettier": "3.9.9",
+ "platform": "win32",
+ "architecture": "arm64",
+ "versionedPrivateEdits": true,
+ "ordinaryHover": true,
+ "dirtyDependencyCompletion": true,
+ "nativeCompletionApplied": true,
+ "unfinishedSignature": true,
+ "definition": true,
+ "memberReferences": true,
+ "lexicalRenameApplied": true,
+ "privateRenameApplied": true,
+ "ordinaryDiagnosticsCleared": true,
+ "lspFormattingRequests": 0,
+ "formatted": [
+ {
+ "file": "main.ts",
+ "version": 9,
+ "sha256": "9d3926519c62e1c34744c9c4f5b9961e0e35b0c33c52de1e40271fe27a098be9",
+ "saveIndex": 3604,
+ "afterHoverId": 12
+ },
+ {
+ "file": "view.tsx",
+ "version": 2,
+ "sha256": "20301d306174f0b147a5a3bdf0bad93a55b0363bae0f75041ab90917a90850d0",
+ "saveIndex": 4442,
+ "afterHoverId": 14
+ },
+ {
+ "file": "dependency.ts",
+ "version": 3,
+ "sha256": "f955260cf15791e9f8bfe9b6b672b48c8c055aab8e00083cf4369a46de6ce4a5",
+ "saveIndex": 4849,
+ "afterHoverId": 17
+ }
+ ],
+ "evidenceDirectory": "artifacts/editor-contract/helix/run-ZAkEIw"
+}
diff --git a/scripts/editor-smoke/helix-editing/prepare.mjs b/scripts/editor-smoke/helix-editing/prepare.mjs
new file mode 100644
index 000000000..d46006045
--- /dev/null
+++ b/scripts/editor-smoke/helix-editing/prepare.mjs
@@ -0,0 +1,75 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
+const hx = path.resolve(process.argv[2] ?? path.join(root, "artifacts/editor-tools/helix-25.07.1-x86_64-windows/hx.exe"));
+const node = process.execPath;
+const server = path.join(root, "src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll");
+const prettier = path.join(root, "tools/formatter-interop/node_modules/prettier/bin/prettier.cjs");
+for (const file of [hx, server, prettier]) await fs.access(file);
+const parent = path.join(root, "artifacts/editor-contract/helix");
+await fs.mkdir(parent, { recursive: true });
+const workspace = await fs.mkdtemp(path.join(parent, "run-"));
+const execute = (command, args, options = {}) => {
+ const result = spawnSync(command, args, { encoding: "utf8", windowsHide: true, timeout: 15000, ...options });
+ assert.equal(result.status, 0, result.stderr || result.error?.message);
+ return result.stdout.trim();
+};
+const formatterVersion = execute(node, [prettier, "--version"]);
+assert.equal(formatterVersion, JSON.parse(await fs.readFile(path.join(root, "tools/formatter-interop/package.json"), "utf8")).devDependencies.prettier);
+const initial = {
+ "dependency.ts": "export class Counter {\n #secret: number = 1;\n value: number = 2;\n add(left: number, right: number): number { return this.#secret + left + right; }\n}\n",
+ "main.ts": "import { Counter } from './dependency';\nconst counter = new Counter();\nconst result = counter.add(1, 2);\nconst lexical = result;\nlexical;\n",
+ "view.tsx": 'declare const React: any;\ndeclare function Panel(props: { title: string }): any;\nconst title = "before";\nconst view = ;\n',
+};
+for (const [name, text] of Object.entries(initial)) await fs.writeFile(path.join(workspace, name), text);
+const stages = {
+ "dependency-dirty": initial["dependency.ts"].replaceAll("#secret", "#renamedSecret").replace(" value:", " extra: number = 4;\n value:"),
+ "main-completion": initial["main.ts"] + "counter.\n",
+ "main-valid": initial["main.ts"].replace("const result =", "const result=").replace("add(1, 2)", "add(1,2)"),
+ "main-bad": initial["main.ts"] + 'const bad: number = "wrong";\n',
+ "view-dirty": initial["view.tsx"].replace('title = "before"', 'title="after"'),
+};
+for (const [name, text] of Object.entries(stages)) await fs.writeFile(path.join(workspace, `${name}.txt`), text);
+// The native :pipe command replaces Helix's selected buffer with stdout, keeping
+// edits unsaved and sending the editor's own didChange. It avoids terminal paste
+// and auto-indent differences; this helper never launches or speaks to the LSP.
+await fs.writeFile(path.join(workspace, "stage.cjs"), 'const fs = require("node:fs"); process.stdout.write(fs.readFileSync(process.argv[2] + ".txt", "utf8"));\n');
+await fs.writeFile(path.join(workspace, "tsconfig.json"), JSON.stringify({ compilerOptions: { noLib: true, types: [], jsx: "react" }, include: ["*.ts", "*.tsx"] }) + "\n");
+await fs.writeFile(path.join(workspace, ".prettierrc.json"), '{"semi":true,"endOfLine":"lf"}\n');
+await fs.writeFile(path.join(workspace, "Smoke.csproj"), ' \n');
+await fs.mkdir(path.join(workspace, "obj/@sharpts/gui"), { recursive: true });
+await fs.writeFile(path.join(workspace, "obj/@sharpts/gui/control-docs.generated.json"), '{"controls":[]}\n');
+await fs.mkdir(path.join(workspace, ".helix"));
+const toml = (value) => JSON.stringify(value.replaceAll("\\", "/"));
+await fs.writeFile(path.join(workspace, ".helix/languages.toml"), `[language-server.sharpts]
+command = "dotnet"
+args = [${toml(server)}, "--language-features", "full", "--diagnostics", "all"]
+
+[[language]]
+name = "typescript"
+language-servers = ["sharpts"]
+auto-format = true
+formatter = { command = ${toml(node)}, args = [${toml(prettier)}, "--stdin-filepath", "%{buffer_name}"] }
+
+[[language]]
+name = "tsx"
+language-servers = ["sharpts"]
+auto-format = true
+formatter = { command = ${toml(node)}, args = [${toml(prettier)}, "--stdin-filepath", "%{buffer_name}"] }
+`);
+await fs.writeFile(path.join(workspace, "config.toml"), '[editor]\nauto-pairs = false\nauto-completion = false\n\n[keys.insert]\nC-k = "signature_help"\n');
+const config = {
+ workspace, hx, runtime: path.join(path.dirname(hx), "runtime"), server, node,
+ editorConfig: path.join(workspace, "config.toml"), log: path.join(workspace, "helix.log"),
+ editor: execute(hx, ["--version"]), formatter: { version: formatterVersion, executable: node, module: prettier },
+ commit: execute("git", ["rev-parse", "HEAD"], { cwd: root }),
+ serverSha256: createHash("sha256").update(await fs.readFile(server)).digest("hex"),
+ platform: process.platform, architecture: process.arch, nodeVersion: process.version, initial, stages,
+};
+await fs.writeFile(path.join(workspace, "test-config.json"), JSON.stringify(config, null, 2) + "\n");
+console.log(JSON.stringify({ workspace, editor: config.editor, log: config.log, editorConfig: config.editorConfig, runtime: config.runtime }, null, 2));
diff --git a/scripts/editor-smoke/helix-editing/trace.mjs b/scripts/editor-smoke/helix-editing/trace.mjs
new file mode 100644
index 000000000..73c44fcb7
--- /dev/null
+++ b/scripts/editor-smoke/helix-editing/trace.mjs
@@ -0,0 +1,45 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+export async function readTrace(workspace) {
+ const config = JSON.parse(await fs.readFile(path.join(workspace, "test-config.json"), "utf8"));
+ const messages = (await fs.readFile(config.log, "utf8")).split(/\r?\n/).flatMap((line, index) => {
+ const match = line.match(/\bsharpts (->|<-) (\{.*\})$/);
+ return match ? [{ direction: match[1], message: JSON.parse(match[2]), index }] : [];
+ });
+ const documents = new Map(), histories = [];
+ const offset = (text, position) => {
+ let start = 0;
+ for (let line = 0; line < position.line; line++) start = text.indexOf("\n", start) + 1;
+ return start + position.character;
+ };
+ for (const item of messages) {
+ if (item.direction !== "->") continue;
+ const { method, params } = item.message;
+ if (method === "textDocument/didOpen") documents.set(params.textDocument.uri.toLowerCase(), { ...params.textDocument });
+ if (method === "textDocument/didChange") {
+ const state = documents.get(params.textDocument.uri.toLowerCase());
+ for (const change of params.contentChanges) {
+ state.text = change.range
+ ? state.text.slice(0, offset(state.text, change.range.start)) + change.text + state.text.slice(offset(state.text, change.range.end))
+ : change.text;
+ }
+ state.version = params.textDocument.version;
+ }
+ if (["textDocument/didOpen", "textDocument/didChange", "textDocument/didSave"].includes(method)) {
+ const state = documents.get(params.textDocument.uri.toLowerCase());
+ if (state) histories.push({ ...state, index: item.index, method });
+ }
+ }
+ const requests = (method) => messages.filter((item) => item.direction === "->" && item.message.method === method).map((request) => ({
+ request, response: messages.find((item) => item.direction === "<-" && item.index > request.index && item.message.id === request.message.id),
+ document: histories.findLast((item) => item.index < request.index && item.uri.toLowerCase() === request.message.params.textDocument?.uri?.toLowerCase()),
+ }));
+ return { config, messages, documents, histories, requests };
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2]) {
+ const trace = await readTrace(path.resolve(process.argv[2]));
+ console.log(JSON.stringify({ documents: [...trace.documents.values()], lastRequests: trace.messages.filter((item) => item.direction === "->" && item.message.id !== undefined).slice(-6) }, null, 2));
+}
diff --git a/scripts/editor-smoke/helix-editing/verify.mjs b/scripts/editor-smoke/helix-editing/verify.mjs
new file mode 100644
index 000000000..c713cd24b
--- /dev/null
+++ b/scripts/editor-smoke/helix-editing/verify.mjs
@@ -0,0 +1,77 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { readTrace } from "./trace.mjs";
+
+const workspace = path.resolve(process.argv[2]);
+const { config, messages, histories, requests } = await readTrace(workspace);
+const initialize = messages.find((item) => item.direction === "->" && item.message.method === "initialize");
+assert.ok(initialize, "native initialize missing");
+assert.equal(initialize.message.params.capabilities.workspace.workspaceEdit.documentChanges, true);
+const initialized = messages.find((item) => item.direction === "<-" && item.message.id === initialize.message.id);
+const caps = initialized.message.result.capabilities;
+for (const name of ["hoverProvider", "completionProvider", "signatureHelpProvider", "definitionProvider", "referencesProvider", "renameProvider"]) assert.ok(caps[name], `${name} missing`);
+for (const name of ["inlayHintProvider", "semanticTokensProvider", "foldingRangeProvider", "documentFormattingProvider", "documentRangeFormattingProvider", "documentOnTypeFormattingProvider"]) assert.ok(!caps[name], `${name} advertised`);
+assert.ok(!messages.some((item) => /^textDocument\/(?:formatting|rangeFormatting|onTypeFormatting)$/.test(item.message.method)), "LSP formatting request");
+assert.ok(!messages.some((item) => item.message.method === "client/registerCapability" && item.message.params.registrations.some((registration) => /formatting|inlayHint|semanticTokens|foldingRange/i.test(registration.method))));
+const successes = (method) => requests(method).filter((item) => item.response && !item.response.message.error && item.response.message.result);
+const hoverText = (item) => JSON.stringify(item.response.message.result.contents);
+const hovers = successes("textDocument/hover");
+assert.ok(hovers.some((item) => hoverText(item).includes("result: number")), "ordinary semantic hover missing");
+const definition = successes("textDocument/definition").find((item) => {
+ const result = item.response.message.result;
+ return (Array.isArray(result) ? result : [result]).some((location) => (location.uri ?? location.targetUri).toLowerCase().endsWith("/dependency.ts"));
+});
+assert.ok(definition, "native cross-file definition missing");
+const references = successes("textDocument/references").find((item) => ["/main.ts", "/dependency.ts"].every((suffix) => item.response.message.result.some((location) => location.uri.toLowerCase().endsWith(suffix))));
+assert.ok(references, "member references missing");
+const completion = successes("textDocument/completion").find((item) => {
+ const items = item.response.message.result.items ?? item.response.message.result;
+ return ["add", "extra", "value"].every((name) => items.some((candidate) => candidate.label === name));
+});
+assert.ok(completion, "receiver completion from dirty dependency missing");
+const dirtyDependency = histories.findLast((item) => item.index < completion.request.index && item.uri.toLowerCase().endsWith("/dependency.ts"));
+assert.ok(dirtyDependency.text.includes("extra: number = 4") && dirtyDependency.method === "textDocument/didChange", "dependency was not dirty");
+assert.ok(!histories.some((item) => item.index < completion.request.index && item.uri.toLowerCase().endsWith("/dependency.ts") && item.method === "textDocument/didSave"));
+const signature = successes("textDocument/signatureHelp").find((item) => item.document?.text.includes("counter.add(\n") && item.response.message.result.signatures.some((candidate) => candidate.label.includes("left: number") && candidate.label.includes("right: number")));
+assert.ok(signature, "accepted native completion / unfinished-call signature missing");
+assert.equal(signature.response.message.result.activeParameter, 0);
+assert.equal(signature.response.message.result.activeSignature, undefined, "recovered hole selected an overload");
+const privateRename = successes("textDocument/rename").find((item) => item.request.message.params.newName === "#renamedSecret");
+assert.ok(privateRename, "private rename missing");
+const privateChanges = privateRename.response.message.result.documentChanges;
+assert.equal(privateChanges.length, 1);
+assert.equal(privateChanges[0].textDocument.version, privateRename.document.version);
+assert.equal(privateChanges[0].edits.length, 2);
+assert.ok(privateChanges[0].edits.every((edit) => edit.newText === "#renamedSecret" && edit.range.end.character - edit.range.start.character === "#secret".length));
+assert.ok(histories.some((item) => item.index > privateRename.response.index && item.uri.toLowerCase().endsWith("/dependency.ts") && item.text.includes("this.#renamedSecret") && !item.text.includes("#secret")), "native private edit not applied");
+assert.ok(hovers.some((item) => hoverText(item).includes("#renamedSecret: number")), "fresh private binding unavailable");
+const lexicalRename = successes("textDocument/rename").find((item) => item.request.message.params.newName === "renamedLocal");
+assert.ok(lexicalRename, "lexical rename missing");
+const diagnostics = messages.filter((item) => item.direction === "<-" && item.message.method === "textDocument/publishDiagnostics");
+assert.ok(diagnostics.some((item) => item.message.params.diagnostics.some((diagnostic) => /wrong|string/.test(diagnostic.message))), "ordinary type diagnostics missing");
+const formatted = [];
+for (const [file, stage, needle] of [["main.ts", "main-valid", "result: number"], ["view.tsx", "view-dirty", "title:"] , ["dependency.ts", "dependency-dirty", "#renamedSecret: number"]]) {
+ const actual = await fs.readFile(path.join(workspace, file), "utf8");
+ let source = await fs.readFile(path.join(workspace, `${stage}.txt`), "utf8");
+ if (file === "main.ts") source = source.replaceAll("lexical", "renamedLocal");
+ const format = spawnSync(config.formatter.executable, [config.formatter.module, "--stdin-filepath", path.join(workspace, file)], { cwd: workspace, input: source, encoding: "utf8", windowsHide: true, timeout: 10000 });
+ assert.equal(format.status, 0, format.stderr);
+ assert.equal(actual, format.stdout, `${file}: pinned formatter bytes differ`);
+ const saves = histories.filter((item) => item.uri.toLowerCase().endsWith(`/${file}`) && item.method === "textDocument/didSave");
+ const save = saves.findLast((item) => item.text === actual);
+ assert.ok(save, `${file}: didSave missing`);
+ assert.ok(hovers.some((item) => item.request.index < save.index && item.document.uri.toLowerCase().endsWith(`/${file}`) && hoverText(item).includes(needle)), `${file}: pre-save hover missing`);
+ const after = hovers.find((item) => item.request.index > save.index && item.document.uri.toLowerCase().endsWith(`/${file}`) && hoverText(item).includes(needle));
+ assert.ok(after, `${file}: fresh post-save hover missing`);
+ const finalDiagnostics = diagnostics.findLast((item) => item.message.params.uri.toLowerCase().endsWith(`/${file}`));
+ assert.equal(finalDiagnostics?.message.params.version, save.version, `${file}: diagnostic version differs`);
+ assert.equal(finalDiagnostics.message.params.diagnostics.length, 0, `${file}: final source did not check cleanly`);
+ formatted.push({ file, version: save.version, sha256: createHash("sha256").update(actual).digest("hex"), saveIndex: save.index, afterHoverId: after.request.message.id });
+}
+assert.ok(messages.some((item) => item.direction === "->" && item.message.method === "shutdown"), "native shutdown missing");
+const result = { completed: true, timestamp: new Date().toISOString(), editor: config.editor, mode: "full", diagnostics: "all", commit: config.commit, serverSha256: config.serverSha256, node: config.nodeVersion, prettier: config.formatter.version, platform: config.platform, architecture: config.architecture, versionedPrivateEdits: true, ordinaryHover: true, dirtyDependencyCompletion: true, nativeCompletionApplied: true, unfinishedSignature: true, definition: true, memberReferences: true, lexicalRenameApplied: true, privateRenameApplied: true, ordinaryDiagnosticsCleared: true, lspFormattingRequests: 0, formatted, workspace, log: config.log };
+await fs.writeFile(path.join(workspace, "result.json"), JSON.stringify(result, null, 2) + "\n");
+console.log(JSON.stringify(result, null, 2));
diff --git a/scripts/editor-smoke/helix/README.md b/scripts/editor-smoke/helix/README.md
new file mode 100644
index 000000000..5c37fedd9
--- /dev/null
+++ b/scripts/editor-smoke/helix/README.md
@@ -0,0 +1,35 @@
+# Helix format-on-save smoke
+
+Install the pinned formatter and build the language server, then prepare an isolated workspace:
+
+```powershell
+npm ci --prefix tools/formatter-interop
+dotnet build src/SharpTS.LanguageServer -c Release
+node scripts/editor-smoke/helix/prepare.mjs --mode full --hx /absolute/path/to/hx.exe
+```
+
+The preparation command prints the editor, runtime, config, log and workspace paths. Launch that
+editor in a real terminal with `HELIX_RUNTIME` set to its extracted `runtime` directory:
+
+```powershell
+hx -c /config.toml --log /helix.log -vvv -w /format-on-save.ts
+```
+
+In each `.ts` and `.tsx` fixture, change `original` to `edited`. Put the cursor on `DotNetType` and
+use Space+k to request hover; confirm the CLR type/XML documentation appears. Use `:write` and
+confirm Prettier expanded the compact source. Request hover again after the save. Open the second
+file with `:open format-on-save.tsx`,
+repeat the edit/hover/save, and quit. Then verify the saved bytes and real LSP log:
+
+```powershell
+node scripts/editor-smoke/helix/verify.mjs artifacts/formatter-interop/helix/full
+```
+
+Repeat preparation and the terminal workflow with `--mode interop-only`, then verify that mode's
+artifact directory. This uses the external formatter and filename expansion from `docs/formatting.md`
+with the real server active. It does not require or test a second TypeScript server.
+
+Verification matches each hover response to its JSON-RPC request and requires successful hovers
+both before and after each formatted `didSave`. It also checks initial and dynamic formatting
+capabilities. An empty generated GUI contract bounds metadata discovery to the disposable fixture;
+the fixture project is never built or restored.
diff --git a/scripts/editor-smoke/helix/prepare.mjs b/scripts/editor-smoke/helix/prepare.mjs
new file mode 100644
index 000000000..4f78ba41a
--- /dev/null
+++ b/scripts/editor-smoke/helix/prepare.mjs
@@ -0,0 +1,76 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
+const options = new Map();
+for (let index = 2; index < process.argv.length; index += 2) {
+ assert.ok(process.argv[index]?.startsWith("--") && process.argv[index + 1], "use --name value pairs");
+ options.set(process.argv[index].slice(2), process.argv[index + 1]);
+}
+const mode = options.get("mode") ?? "full";
+assert.ok(["full", "interop-only"].includes(mode));
+const hx = path.resolve(root, options.get("hx") ?? "artifacts/editor-tools/helix-25.07.1-x86_64-windows/hx.exe");
+const server = path.resolve(root, options.get("server") ?? "src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll");
+const workspace = path.resolve(root, options.get("artifacts") ?? `artifacts/formatter-interop/helix/${mode}`);
+const prettier = path.join(root, "tools/formatter-interop/node_modules/prettier/bin/prettier.cjs");
+await fs.access(hx);
+await fs.access(server);
+const pin = JSON.parse(await fs.readFile(path.join(root, "tools/formatter-interop/package.json"), "utf8")).devDependencies.prettier;
+const installed = spawnSync(process.execPath, [prettier, "--version"], { encoding: "utf8", windowsHide: true, timeout: 10000 });
+assert.equal(installed.status, 0, installed.stderr);
+const prettierVersion = installed.stdout.trim();
+assert.equal(prettierVersion, pin, "installed formatter differs from the exact repository pin");
+await fs.mkdir(path.join(workspace, ".helix"), { recursive: true });
+await fs.writeFile(path.join(workspace, ".prettierrc.json"), '{"semi":true,"endOfLine":"lf"}\n');
+// Bound GUI metadata discovery to this disposable fixture instead of user ancestors.
+await fs.writeFile(path.join(workspace, "Smoke.csproj"), ' \n');
+await fs.mkdir(path.join(workspace, "obj/@sharpts/gui"), { recursive: true });
+await fs.writeFile(path.join(workspace, "obj/@sharpts/gui/control-docs.generated.json"), '{"controls":[]}\n');
+const tomlString = (value) => JSON.stringify(value.replaceAll("\\", "/"));
+const formatter = `{ command = "node", args = [${tomlString(prettier)}, "--stdin-filepath", "%{buffer_name}"] }`;
+await fs.writeFile(path.join(workspace, ".helix/languages.toml"), `
+[language-server.sharpts]
+command = "dotnet"
+args = [${tomlString(server)}, "--language-features", "${mode}", "--diagnostics", "${mode === "full" ? "all" : "sharpts-only"}"]
+
+[[language]]
+name = "typescript"
+language-servers = ["sharpts"]
+auto-format = true
+formatter = ${formatter}
+
+[[language]]
+name = "tsx"
+language-servers = ["sharpts"]
+auto-format = true
+formatter = ${formatter}
+`);
+await fs.writeFile(path.join(workspace, "config.toml"), '[editor]\nauto-pairs = false\n');
+const fixtures = [];
+for (const extension of ["ts", "tsx"]) {
+ const file = path.join(workspace, `format-on-save.${extension}`);
+ const original = '@DotNetType("System.Text.StringBuilder")\ndeclare class Builder{constructor();append(value:string):Builder;}\nconst message={text:"original",count:1};\n'
+ + (extension === "tsx" ? 'declare function Panel(props:{title:string}):any;\nconst view= ;\n' : "");
+ const edited = original.replace('text:"original"', 'text:"edited"');
+ const format = spawnSync(process.execPath, [prettier, "--stdin-filepath", file], {
+ input: edited, cwd: workspace, encoding: "utf8", windowsHide: true, timeout: 10000,
+ });
+ assert.equal(format.status, 0, format.stderr);
+ assert.notEqual(edited, format.stdout);
+ await fs.writeFile(file, original);
+ fixtures.push({ file, edited, expected: format.stdout });
+}
+const version = spawnSync(hx, ["--version"], { encoding: "utf8", windowsHide: true });
+assert.equal(version.status, 0, version.stderr);
+const config = {
+ mode, workspace, hx, server, fixtures, prettierVersion, version: version.stdout.trim(),
+ runtime: path.join(path.dirname(hx), "runtime"),
+ log: path.join(workspace, "helix.log"),
+ editorConfig: path.join(workspace, "config.toml"),
+};
+await fs.rm(config.log, { force: true });
+await fs.writeFile(path.join(workspace, "test-config.json"), JSON.stringify(config, null, 2) + "\n");
+console.log(JSON.stringify(config, null, 2));
diff --git a/scripts/editor-smoke/helix/verify.mjs b/scripts/editor-smoke/helix/verify.mjs
new file mode 100644
index 000000000..f245ea295
--- /dev/null
+++ b/scripts/editor-smoke/helix/verify.mjs
@@ -0,0 +1,68 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const workspace = path.resolve(process.argv[2] ?? "artifacts/formatter-interop/helix/full");
+const config = JSON.parse(await fs.readFile(path.join(workspace, "test-config.json"), "utf8"));
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
+const pin = JSON.parse(await fs.readFile(path.join(root, "tools/formatter-interop/package.json"), "utf8")).devDependencies.prettier;
+const installed = spawnSync(process.execPath, [path.join(root, "tools/formatter-interop/node_modules/prettier/bin/prettier.cjs"), "--version"],
+ { encoding: "utf8", windowsHide: true, timeout: 10000 });
+assert.equal(installed.status, 0, installed.stderr);
+const prettierVersion = installed.stdout.trim();
+assert.equal(prettierVersion, pin, "installed formatter differs from the exact repository pin");
+if (config.prettierVersion) assert.equal(prettierVersion, config.prettierVersion);
+const log = await fs.readFile(config.log, "utf8");
+const messages = log.split(/\r?\n/).flatMap((line, index) => {
+ const match = line.match(/\bsharpts (->|<-) (\{.*\})$/);
+ return match ? [{ direction: match[1], message: JSON.parse(match[2]), index }] : [];
+});
+const initialize = messages.findLast((item) => item.direction === "->" && item.message.method === "initialize");
+assert.ok(initialize, "no real LSP initialize request");
+const initialized = messages.find((item) => item.index > initialize.index && item.direction === "<-"
+ && item.message.id === initialize.message.id);
+assert.ok(initialized?.message.result?.capabilities, "no successful initialize response");
+const capabilities = initialized.message.result.capabilities;
+for (const name of ["documentFormattingProvider", "documentRangeFormattingProvider", "documentOnTypeFormattingProvider"]) {
+ assert.ok(!capabilities[name], `SharpTS advertised ${name}`);
+}
+assert.equal(Boolean(capabilities.definitionProvider), config.mode === "full", "wrong feature mode");
+const formattingMethod = /^textDocument\/(formatting|rangeFormatting|onTypeFormatting)$/;
+for (const item of messages.filter((item) => item.direction === "<-" && item.message.method === "client/registerCapability")) {
+ assert.ok(!item.message.params.registrations.some((registration) => formattingMethod.test(registration.method)),
+ "SharpTS dynamically registered formatting");
+}
+const hoverEvidence = [];
+for (const fixture of config.fixtures) {
+ assert.equal(await fs.readFile(fixture.file, "utf8"), fixture.expected, `save bytes differ: ${fixture.file}`);
+ const uri = pathToFileURL(fixture.file).href.toLowerCase();
+ const save = messages.find((item) => item.direction === "->" && item.message.method === "textDocument/didSave"
+ && item.message.params.textDocument.uri.toLowerCase() === uri);
+ assert.ok(save, `no real didSave for ${fixture.file}`);
+ assert.equal(save.message.params.text, fixture.expected, "didSave did not contain formatted source");
+ const hovers = messages.filter((item) => item.direction === "->" && item.message.method === "textDocument/hover"
+ && item.message.params.textDocument.uri.toLowerCase() === uri).flatMap((request) => {
+ const response = messages.find((item) => item.index > request.index && item.direction === "<-"
+ && item.message.id === request.message.id);
+ const contents = response?.message.result?.contents?.value;
+ return !response?.message.error && contents?.includes("System.Text.StringBuilder")
+ && contents.includes("Represents a mutable string of characters") ? [{ request, response }] : [];
+ });
+ const before = hovers.find((item) => item.response.index < save.index);
+ const after = hovers.find((item) => item.request.index > save.index);
+ assert.ok(before, `no successful pre-save hover for ${fixture.file}`);
+ assert.ok(after, `no successful fresh post-save hover for ${fixture.file}`);
+ hoverEvidence.push({ file: fixture.file, beforeRequest: before.request.message.id, afterRequest: after.request.message.id });
+}
+assert.ok(!messages.some((item) => formattingMethod.test(item.message.method)),
+ "formatting was incorrectly delegated to LSP");
+const result = {
+ completed: true, timestamp: new Date().toISOString(), editor: config.version,
+ mode: config.mode, node: process.version, prettier: prettierVersion,
+ formatted: config.fixtures.map((fixture) => fixture.file),
+ hoverEvidence, interopHover: true, lspFormattingRequests: 0, log: config.log,
+};
+await fs.writeFile(path.join(workspace, "result.json"), JSON.stringify(result, null, 2) + "\n");
+console.log(JSON.stringify(result, null, 2));
diff --git a/scripts/editor-smoke/last-verified.json b/scripts/editor-smoke/last-verified.json
new file mode 100644
index 000000000..f1f15cf7e
--- /dev/null
+++ b/scripts/editor-smoke/last-verified.json
@@ -0,0 +1,124 @@
+{
+ "schemaVersion": 1,
+ "verifiedUtc": "2026-10-08T05:22:07Z",
+ "source": {
+ "commit": "a20b07d7259043dd06a33fae34a507b443c847d8",
+ "workingTreeChangesIncluded": true,
+ "configuration": "Release"
+ },
+ "toolchain": {
+ "platform": "Windows 10.0.26220 ARM64",
+ "dotnetSdk": "10.0.401",
+ "sdkPolicy": "global.json: 10.0.400, latestFeature, no prerelease",
+ "node": "22.23.2",
+ "nodePolicy": ".node-version",
+ "nodeAcquisition": {
+ "source": "https://nodejs.org/dist/v22.23.2/node-v22.23.2-win-arm64.zip",
+ "checksumSource": "https://nodejs.org/dist/v22.23.2/SHASUMS256.txt",
+ "sha256": "fec025a6da31757e3b6af84c5a1628e9d38442ca99a2161091d78f2fcfa35ef3",
+ "installation": "Isolated under artifacts/editor-tools; no global installation"
+ }
+ },
+ "binaries": [
+ {
+ "path": "src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll",
+ "sha256": "9dc0e3c3b4d401e833735179ee84185c460b9f2dcb2ae187f71f8e808627a40b"
+ },
+ {
+ "path": "src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.dll",
+ "sha256": "a2ee2949aace31ab14706d2bf2352125ed94d9fed085cb76ec7b55d95909e45b"
+ },
+ {
+ "path": "tests/fixtures/FormatterInterop/bin/Release/net10.0/SharpTS.FormatterInteropEvidence.dll",
+ "sha256": "9a1e32d7b02685de0b34219d39e371d08281123aab6e36ebbd7070a9f4c49f00"
+ },
+ {
+ "path": "tests/fixtures/FormatterInterop/bin/Release/net10.0/SharpTS.dll",
+ "sha256": "a2ee2949aace31ab14706d2bf2352125ed94d9fed085cb76ec7b55d95909e45b"
+ }
+ ],
+ "aggregate": {
+ "status": "passed",
+ "command": "pwsh -NoProfile -NonInteractive -File scripts/test-editor-contract.ps1 -NoBuild -NoRestore -NoFormatterInstall -NodeExecutable artifacts/editor-tools/node-22.23.2/node-v22.23.2-win-arm64/node.exe",
+ "evidence": "artifacts/editor-contract/20261007T215921-0682873b/index.json",
+ "suitesPassed": 8,
+ "suites": [
+ "analysis-snapshots",
+ "semantic-hover",
+ "semantic-completion",
+ "semantic-signatures",
+ "member-references",
+ "private-rename",
+ "formatter-interop",
+ "packaged-tool"
+ ],
+ "featureModes": ["full", "interop-only"],
+ "packagedTool": {
+ "version": "0.0.0-editor-contract",
+ "package": "artifacts/editor-contract/20261007T215921-0682873b/packaged/feed/SharpTS.LanguageServer.0.0.0-editor-contract.nupkg",
+ "sha256": "9b9233cd81cb7c6e03745623c094ee5a2266bb0feb770c0ae6ace7163efe235b",
+ "execution": "Actual isolated sharpts-lsp executable shim; local-only NuGet feed",
+ "cleanup": "Tool installation, package cache, CLI home and HTTP cache removed"
+ }
+ },
+ "focusedTests": [
+ {
+ "status": "passed",
+ "passed": 157,
+ "filter": "FullyQualifiedName~AnalysisHandlerTests|FullyQualifiedName~SemanticCompletionServiceTests|FullyQualifiedName~SemanticSignatureHelpServiceTests|FullyQualifiedName~SemanticCompletionHandlerTests|FullyQualifiedName~SemanticSignatureHelpHandlerTests",
+ "evidence": "artifacts/editor-release-checks/final-focused/editor-final-focused.trx"
+ },
+ {
+ "status": "passed",
+ "passed": 21,
+ "filter": "FullyQualifiedName~AnalysisHandlerTests",
+ "evidence": "artifacts/editor-release-checks/final-focused/analysis-handler-isolation.trx",
+ "note": "Final test-only rebuild uses an isolated xUnit collection; assertions and deadlines retained"
+ }
+ ],
+ "releaseChecks": {
+ "status": "passed",
+ "evidence": "artifacts/editor-release-checks/4ae3e2e514c24b1a889fcb9b2f2c15aa/index.json",
+ "commands": [
+ "pwsh -NoProfile -NonInteractive -File scripts/test-code-quality.ps1",
+ "pwsh -NoProfile -NonInteractive -File scripts/test-nuget-release.ps1",
+ "pwsh -NoProfile -NonInteractive -File scripts/test-gui-version.ps1",
+ "dotnet test tests/gui-conformance/SharpTS.Gui.Conformance.Tests/SharpTS.Gui.Conformance.Tests.csproj --no-build --configuration Release --verbosity normal --filter \"Category!=LiveNetwork&Category!=LoadSensitive&Category!=npm\"",
+ "dotnet publish src/SharpTS/SharpTS.csproj --no-build --no-restore --configuration Release --nologo --verbosity minimal",
+ "dotnet pack src/SharpTS.Sdk/SharpTS.Sdk.csproj --no-build --no-restore --configuration Release --output artifacts/editor-release-checks/4ae3e2e514c24b1a889fcb9b2f2c15aa/sdk-feed -p:MinVerVersionOverride=0.0.0-sdk-smoke",
+ "pwsh -NoProfile -NonInteractive -File scripts/test-packaged-sdk.ps1 -PackagePath artifacts/editor-release-checks/4ae3e2e514c24b1a889fcb9b2f2c15aa/sdk-feed/SharpTS.Sdk.0.0.0-sdk-smoke.nupkg -PackageVersion 0.0.0-sdk-smoke"
+ ],
+ "codeQuality": "Final run passed: 27 duplicate groups, 0 errors. Initial DUP001 failure remains recorded and superseded by the shared contextual-identifier helper fix",
+ "nugetReleasePassed": 10,
+ "guiConformancePassed": 134,
+ "sdkConsumer": {
+ "version": "0.0.0-sdk-smoke",
+ "sha256": "e27af902d0bfcebc28a94d3b1a5d2281302f1f6d44999fbf75f02c6864a71f91",
+ "result": "Isolated local-package consumer build, rebuild, clean, publish and sdk-smoke-ok execution passed"
+ }
+ },
+ "workflowPolicy": {
+ "status": "passed",
+ "command": "pwsh -NoProfile -NonInteractive -File scripts/test-github-workflows.ps1",
+ "workflowsValidated": 9
+ },
+ "fullCoreTests": {
+ "status": "passed",
+ "passed": 25210,
+ "skipped": 3,
+ "failed": 0,
+ "filter": "Category!=LiveNetwork&Category!=LoadSensitive&Category!=npm&FullyQualifiedName!~SharpTS.Tests.CompilerTests.StandaloneDllTests.",
+ "evidence": "artifacts/tests/1981-core-ci-final.trx",
+ "note": "Final run after isolating deliberately blocking checker-gate tests from unrelated aggressive xUnit collections; assertions and deadlines retained"
+ },
+ "actualEditors": {
+ "vscode": "scripts/editor-smoke/vscode-shipping/last-verified.json",
+ "neovim": "scripts/editor-smoke/neovim-editing/last-verified.json",
+ "helix": "scripts/editor-smoke/helix-editing/last-verified.json"
+ },
+ "workflowMeasurements": "benchmarks/editor-workflow/results-windows-arm64.json",
+ "pending": {
+ "remoteCiMatrix": "Pending GitHub execution of editor-contract on ubuntu-24.04 and windows-2025"
+ },
+ "artifactPolicy": "Raw run artifacts are ignored local files. The CI editor-contract job uploads logs and evidence on success or failure for 7 days. This tracked index contains repository-relative pointers and hashes, not the raw artifacts"
+}
diff --git a/scripts/editor-smoke/neovim-editing/README.md b/scripts/editor-smoke/neovim-editing/README.md
new file mode 100644
index 000000000..c118ce040
--- /dev/null
+++ b/scripts/editor-smoke/neovim-editing/README.md
@@ -0,0 +1,97 @@
+# Native Neovim editing and coexistence smoke
+
+This optional smoke runs Neovim's actual native LSP client, buffer notifications,
+floating hover UI, completion text-edit application, and workspace-edit application.
+It uses the Release language-server DLL already on disk and never builds it.
+
+Prepare the Release server with the repository's normal .NET build, install
+Neovim 0.12.5, and install the two optional locked tool sets:
+
+```sh
+npm ci --prefix tools/formatter-interop --ignore-scripts --no-audit --no-fund
+npm ci --prefix tools/editor-interop --ignore-scripts --no-audit --no-fund
+node scripts/editor-smoke/neovim-editing/run.mjs --nvim /absolute/path/to/nvim
+```
+
+On the recorded Windows ARM64 host, the default editor path is
+`artifacts/editor-tools/nvim-win-arm64/bin/nvim.exe`. Other installations should
+pass `--nvim` with an absolute executable path. Optional `--server`, `--dotnet`,
+and `--artifacts` arguments accept a value after each flag. No Neovim plugins or
+user configuration are loaded (`--headless -u NONE`); XDG directories are isolated
+under the run directory. Each editor process has a two-minute hard deadline and
+stops its servers in cleanup. This is editor contract verification, not a latency
+benchmark or a proof of every interactive UI command.
+
+## Tested scenarios
+
+Full-alone starts SharpTS with the explicit arguments
+`--language-features full --diagnostics all`. It verifies:
+
+- Ordinary hover in the native floating window, cross-file member definition,
+ and three references, including an exact UTF-16 range after a non-BMP character.
+- Unfinished `item.` completion, application of the returned edit through
+ `vim.lsp.util.apply_text_edits`, and a fresh hover on the inserted member.
+- Unfinished `pair(1, ` signature help with the second active parameter and no
+ fabricated selected signature.
+- Lexical rename applied through `vim.lsp.util.apply_workspace_edit` and a fresh
+ hover on the new name.
+- Dirty dependency changes, importer diagnostics, shifted definition ranges,
+ and disk-source restoration after discarding and closing the dependency buffer.
+- One documented external Prettier save hook for `.ts` and `.tsx`, exact saved
+ bytes, and fresh post-format requests. SharpTS advertises no LSP formatting.
+- Private rename refusal with Neovim's default capabilities, which omit
+ `workspace.workspaceEdit.documentChanges` in the tested version.
+- A separate explicitly opted-in client applying the versioned private edit
+ natively; the selected domain changes while another class's `#old` stays intact.
+
+Coexistence starts both actual processes in the same buffers:
+
+```text
+SharpTS: --language-features interop-only --diagnostics sharpts-only
+TypeScript: node tools/editor-interop/node_modules/typescript-language-server/lib/cli.mjs --stdio
+```
+
+The TypeScript adapter is **6.0.1** and its backend is **TypeScript 6.0.3**, both
+exactly locked in `tools/editor-interop`. The adapter's
+[tagged installation instructions](https://github.com/typescript-language-server/typescript-language-server/blob/v6.0.1/README.md)
+require TypeScript 6 because TypeScript 7 does not expose the tsserver API.
+The [adapter release](https://github.com/typescript-language-server/typescript-language-server/releases/tag/v6.0.1)
+is community maintained; it is separate from Microsoft's VS Code TypeScript
+extension. The smoke supplies the exact `tsserver.js` path, disables automatic
+typing acquisition, uses one semantic server, and verifies the backend's real
+`$/typescriptVersion` notification. Node must satisfy the locked adapter's
+`>=22.22.2` engine requirement.
+
+Native definition dispatch has exactly one ordinary owner, TypeScript; SharpTS
+does not advertise definition, references, or rename in interop mode. Ordinary
+hover is available only from TypeScript, and an introduced ordinary type error
+appears only in TypeScript diagnostics. At the `@DotNetType` fixture, only SharpTS
+returns the CLR-qualified description. Neovim 0.12.5's native `vim.lsp.buf.hover`
+merges the attached clients' responses, and its real floating window must contain
+that CLR description. No custom hover routing replaces the native client.
+
+The same one external Prettier `BufWritePre` hook from
+[`docs/formatting.md`](../../../../docs/formatting.md) handles saves in both modes.
+Although the TypeScript adapter advertises formatting, this setup never calls
+LSP formatting. The hook is the only formatting owner. Expected Prettier output
+uses the same fixture working directory, real filename, and discovered config
+as the native save hook. SharpTS post-save requests wait for diagnostics for the
+exact new document version. The TypeScript adapter deduplicates unchanged
+diagnostics, so its post-save check is a fresh native request without requiring
+a duplicate diagnostic publication.
+
+## Evidence
+
+Every run writes a unique `artifacts/editor-editing/neovim/run-*/summary.json`,
+per-mode `result.json`, process logs, isolated native LSP debug logs, tsserver
+logs, initial/final source, effective commands/capabilities, and configuration.
+The summary records OS/architecture, Node/.NET/editor/tool versions, source
+commit and dirty-state flag, server/core DLL hashes, harness hashes, tool-lock hash
+and extracted formatter-recipe hash. A failure includes its named
+scenario and preserves the workspace. `last-verified.json` is a compact tracked
+record of the successful Windows ARM64 run; raw logs stay in ignored artifacts.
+
+The harness is portable through the explicit editor path but the recorded
+execution currently covers Windows ARM64. The aggregate CI protocol/package
+checks cover supported Windows and Ubuntu hosts separately; this native editor
+run does not imply an unrecorded host or editor version was exercised.
diff --git a/scripts/editor-smoke/neovim-editing/last-verified.json b/scripts/editor-smoke/neovim-editing/last-verified.json
new file mode 100644
index 000000000..a47481e32
--- /dev/null
+++ b/scripts/editor-smoke/neovim-editing/last-verified.json
@@ -0,0 +1,59 @@
+{
+ "timestamp": "2026-10-08T05:01:55.924Z",
+ "platform": "win32/arm64",
+ "sourceCommit": "a20b07d7259043dd06a33fae34a507b443c847d8",
+ "sourceDirty": true,
+ "serverSha256": "9dc0e3c3b4d401e833735179ee84185c460b9f2dcb2ae187f71f8e808627a40b",
+ "coreSha256": "a2ee2949aace31ab14706d2bf2352125ed94d9fed085cb76ec7b55d95909e45b",
+ "node": "v25.5.0",
+ "dotnet": "10.0.401",
+ "editor": "NVIM v0.12.5",
+ "prettier": "3.9.9",
+ "typescriptAdapter": "6.0.1",
+ "typescript": "6.0.3",
+ "harnessSha256": "10c62a8db3154401a1c0c64ffac3876767f440bf7797a688ce7cfcf6858a61d7",
+ "runnerSha256": "493a7eac9c3534c46926a61b33982e6b94a6a2587227b358e9df2ec384523021",
+ "adapterLockSha256": "c62d961f3693d9b9e647f545ddb7632f819ebdee87ccaa3ac133b7ee532368c8",
+ "formattingRecipeSha256": "c384991e15fe5f8320b1607d73656f8ae103fa5095cd7d4abc4eccdbaf252c72",
+ "completed": true,
+ "rawEvidence": "artifacts/editor-editing/neovim/run-m3aV2X/summary.json",
+ "modes": [
+ {
+ "mode": "full",
+ "completed": true,
+ "cases": [
+ "full-native-hover-definition-references",
+ "unfinished-receiver-completion-applied-by-native-client",
+ "unfinished-call-signatures-without-a-false-winner",
+ "native-lexical-workspace-edit-and-fresh-hover",
+ "dirty-dependency-diagnostics-target-ranges-and-close",
+ "ts-and-tsx-one-save-formatter-with-fresh-language-requests",
+ "native-default-capabilities-refuse-private-edits",
+ "explicit-versioned-capability-applies-private-domain-natively"
+ ],
+ "defaultPrivateRename": "refused: documentChanges is absent from native capabilities",
+ "versionedPrivateRename": {
+ "applied": true,
+ "version": 0
+ }
+ },
+ {
+ "mode": "coexistence",
+ "completed": true,
+ "cases": [
+ "two-real-servers-have-one-ordinary-navigation-owner",
+ "ordinary-diagnostics-have-only-the-typescript-owner",
+ "unique-clr-response-reaches-native-merged-hover",
+ "coexistence-ts-tsx-formatting-stays-editor-owned"
+ ],
+ "typescriptBackend": {
+ "source": "user-setting",
+ "version": "6.0.3"
+ },
+ "ordinaryNavigationOwner": "typescript",
+ "ordinaryDiagnosticsOwner": "typescript",
+ "clrHoverOwner": "sharpts",
+ "formattingOwner": "one external Prettier BufWritePre hook; no LSP formatting call"
+ }
+ ]
+}
diff --git a/scripts/editor-smoke/neovim-editing/run.mjs b/scripts/editor-smoke/neovim-editing/run.mjs
new file mode 100644
index 000000000..4d69bdc81
--- /dev/null
+++ b/scripts/editor-smoke/neovim-editing/run.mjs
@@ -0,0 +1,134 @@
+import assert from "node:assert/strict";
+import { spawn, spawnSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const directory = path.dirname(fileURLToPath(import.meta.url));
+const root = path.resolve(directory, "../../..");
+const options = new Map();
+for (let index = 2; index < process.argv.length; index += 2) {
+ assert.ok(process.argv[index]?.startsWith("--") && process.argv[index + 1], "use --name value pairs");
+ options.set(process.argv[index].slice(2), process.argv[index + 1]);
+}
+const nvim = options.has("nvim") ? path.resolve(options.get("nvim"))
+ : process.platform === "win32" ? path.join(root, "artifacts/editor-tools/nvim-win-arm64/bin/nvim.exe") : "nvim";
+const server = path.resolve(root, options.get("server") ?? "src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll");
+const core = path.join(path.dirname(server), "SharpTS.dll");
+const dotnet = options.get("dotnet") ?? "dotnet";
+const toolRoot = path.join(root, "tools/editor-interop");
+const typescriptCli = path.join(toolRoot, "node_modules/typescript-language-server/lib/cli.mjs");
+const tsserver = path.join(toolRoot, "node_modules/typescript/lib/tsserver.js");
+const prettier = path.join(root, "tools/formatter-interop/node_modules/prettier/bin/prettier.cjs");
+const artifacts = path.resolve(root, options.get("artifacts") ?? "artifacts/editor-editing/neovim");
+const hash = (text) => createHash("sha256").update(text).digest("hex");
+function output(command, args, input) {
+ const child = spawnSync(command, args, { encoding: "utf8", input, cwd: root, windowsHide: true, timeout: 15_000 });
+ assert.equal(child.error, undefined, child.error?.message);
+ assert.equal(child.status, 0, child.stderr || child.stdout);
+ return child.stdout.trimEnd();
+}
+for (const filename of [server, core, typescriptCli, tsserver, prettier]) await fs.access(filename);
+const pins = JSON.parse(await fs.readFile(path.join(toolRoot, "package.json"), "utf8")).devDependencies;
+const actualAdapter = output(process.execPath, [typescriptCli, "--version"]);
+const actualTypescript = JSON.parse(await fs.readFile(path.join(toolRoot, "node_modules/typescript/package.json"), "utf8")).version;
+assert.equal(actualAdapter, pins["typescript-language-server"], "Run npm ci --prefix tools/editor-interop");
+assert.equal(actualTypescript, pins.typescript, "Run npm ci --prefix tools/editor-interop");
+const prettierPin = JSON.parse(await fs.readFile(path.join(root, "tools/formatter-interop/package.json"), "utf8")).devDependencies.prettier;
+assert.equal(output(process.execPath, [prettier, "--version"]), prettierPin, "Run npm ci --prefix tools/formatter-interop");
+const editorVersion = output(nvim, ["--version"]).split(/\r?\n/)[0];
+const formattingDoc = await fs.readFile(path.join(root, "docs/formatting.md"), "utf8");
+const saveRecipe = formattingDoc.match(/```lua\r?\n([\s\S]*?)\r?\n```/)?.[1];
+assert.ok(saveRecipe, "Missing documented Neovim save recipe");
+await fs.mkdir(artifacts, { recursive: true });
+const runDirectory = await fs.mkdtemp(path.join(artifacts, "run-"));
+const dependency = "export class Model { /*depDeclaration*/field: number = 1; read(): number { return this./*self*/field; } }\n" +
+ "export function pair(value: number, count: number): number { return value + count; }\n";
+const main = "import { Model, pair } from './dep';\nconst item = new Model();\nconst /*lexicalDeclaration*/local = 1;\n" +
+ "/* 😀 */ item./*member*/field;\n/*lexicalUse*/local;\npair(/*argument*/1, 2);\n";
+const tsx = "/** @jsx h */\ndeclare function h(...args: any[]): any;\ndeclare function Panel(props: { title: string }): any;\n" +
+ "const /*tsxDeclaration*/tsxValue: number = 1;\n/*tsxUse*/tsxValue;\nconst view=;\n";
+const clr = "declare function DotNetType(name: string): any;\n/*clr*/@DotNetType(\"System.Text.StringBuilder\")\n" +
+ "declare class Builder{constructor();append(value:string):Builder;}\n";
+const privateSource = "class Box { /*privateDeclaration*/#old: number = 1; read(): number { return this./*privateUse*/#old; } }\n" +
+ "class Other { #old: number = 2; read(): number { return this.#old; } }\n";
+const report = {
+ timestamp: new Date().toISOString(), platform: `${process.platform}/${process.arch}`,
+ sourceCommit: output("git", ["rev-parse", "HEAD"]), sourceDirty: output("git", ["status", "--porcelain"]).length > 0,
+ server, serverSha256: hash(await fs.readFile(server)), coreSha256: hash(await fs.readFile(core)),
+ node: process.version, dotnet: output(dotnet, ["--version"]), editor: editorVersion,
+ prettier: prettierPin, typescriptAdapter: actualAdapter, typescript: actualTypescript,
+ harnessSha256: hash(await fs.readFile(path.join(directory, "test.lua"))), results: [],
+ runnerSha256: hash(await fs.readFile(fileURLToPath(import.meta.url))),
+ adapterLockSha256: hash(await fs.readFile(path.join(toolRoot, "package-lock.json"))),
+ formattingRecipeSha256: hash(saveRecipe),
+};
+try {
+ for (const mode of ["full", "coexistence"]) {
+ console.log(`Neovim editing: ${mode}`);
+ const workspace = path.join(runDirectory, mode);
+ await fs.mkdir(workspace);
+ await fs.writeFile(path.join(workspace, "tsconfig.json"), JSON.stringify({
+ compilerOptions: { noLib: true, types: [], target: "ES2022", jsx: "react", experimentalDecorators: true },
+ include: ["*.ts", "*.tsx"],
+ }) + "\n");
+ await fs.writeFile(path.join(workspace, ".prettierrc.json"), '{"semi":true,"singleQuote":true,"endOfLine":"lf"}\n');
+ // Bound GUI discovery to this disposable project; never build/restore the project.
+ await fs.writeFile(path.join(workspace, "Smoke.csproj"), ' \n');
+ await fs.mkdir(path.join(workspace, "obj/@sharpts/gui"), { recursive: true });
+ await fs.writeFile(path.join(workspace, "obj/@sharpts/gui/control-docs.generated.json"), '{"controls":[]}\n');
+ const files = {};
+ for (const [name, text] of Object.entries({ "main.ts": main, "dep.ts": dependency, "view.tsx": tsx, "clr.ts": clr, "private.ts": privateSource })) {
+ const filename = path.join(workspace, name);
+ await fs.writeFile(filename, text);
+ const formatted = spawnSync(process.execPath, [prettier, "--stdin-filepath", filename], {
+ input: text, encoding: "utf8", windowsHide: true, timeout: 10_000, cwd: workspace,
+ });
+ assert.equal(formatted.status, 0, formatted.stderr);
+ files[name] = { path: filename, source: text, formatted: formatted.stdout, sha256: hash(text) };
+ }
+ const recipe = path.join(workspace, "format-on-save.lua");
+ await fs.writeFile(recipe, saveRecipe.replaceAll("/absolute/path/to/SharpTS", root.replaceAll("\\", "/")));
+ const config = { mode, workspace, server, dotnet, node: process.execPath, typescriptCli, tsserver,
+ typescript: actualTypescript, recipe, files, result: path.join(workspace, "result.json") };
+ const configPath = path.join(workspace, "test-config.json");
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n");
+ const child = spawn(nvim, ["--headless", "-u", "NONE", "-l", path.join(directory, "test.lua")], {
+ cwd: workspace, windowsHide: true, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"],
+ env: { ...process.env, SHARPTS_EDITOR_SMOKE_CONFIG: configPath,
+ XDG_CONFIG_HOME: path.join(workspace, "config"), XDG_DATA_HOME: path.join(workspace, "data"),
+ XDG_STATE_HOME: path.join(workspace, "state"), XDG_CACHE_HOME: path.join(workspace, "cache"),
+ NVIM_APPNAME: "sharpts-editing-smoke", NVIM_LOG_FILE: path.join(workspace, "nvim.log") },
+ });
+ let log = "";
+ let timedOut = false;
+ child.stdout.on("data", (chunk) => { log = (log + chunk).slice(-1_000_000); });
+ child.stderr.on("data", (chunk) => { log = (log + chunk).slice(-1_000_000); });
+ const timer = setTimeout(() => {
+ timedOut = true;
+ if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true });
+ else { try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } }
+ }, 120_000);
+ let code;
+ try { code = await new Promise((resolve, reject) => { child.once("error", reject); child.once("close", resolve); }); }
+ finally { clearTimeout(timer); await fs.writeFile(path.join(workspace, "process.log"), log); }
+ let result;
+ try { result = JSON.parse(await fs.readFile(config.result, "utf8")); }
+ catch (error) { throw new Error(`${mode}: editor produced no result: ${log}`, { cause: error }); }
+ report.results.push(result);
+ assert.equal(timedOut, false, `${mode}: editor exceeded its process deadline`);
+ assert.equal(code, 0, `${mode}: ${result.error ?? log}`);
+ assert.equal(result.completed, true, JSON.stringify(result));
+ console.log(`Neovim editing: ${mode} passed ${result.cases.length} cases`);
+ }
+ report.completed = true;
+} catch (error) {
+ report.completed = false;
+ report.error = error.message;
+ throw error;
+} finally {
+ await fs.writeFile(path.join(runDirectory, "summary.json"), JSON.stringify(report, null, 2) + "\n");
+ console.log(JSON.stringify({ completed: report.completed, report: path.join(runDirectory, "summary.json"),
+ error: report.error, modes: report.results.map((result) => result.mode) }, null, 2));
+}
diff --git a/scripts/editor-smoke/neovim-editing/test.lua b/scripts/editor-smoke/neovim-editing/test.lua
new file mode 100644
index 000000000..eaff703ea
--- /dev/null
+++ b/scripts/editor-smoke/neovim-editing/test.lua
@@ -0,0 +1,361 @@
+local config = vim.json.decode(table.concat(vim.fn.readfile(assert(vim.env.SHARPTS_EDITOR_SMOKE_CONFIG)), '\n'))
+local result = { mode = config.mode, editor = tostring(vim.version()), cases = {}, sources = {}, clients = {}, observations = {} }
+local clients, diagnostics, diagnostic_counts = {}, {}, {}
+local phase = 'start'
+vim.o.swapfile = false
+vim.lsp.log.set_level('debug')
+result.log = vim.lsp.log.get_filename()
+local default_diagnostics = vim.lsp.handlers['textDocument/publishDiagnostics']
+local function present(value) return value ~= nil and value ~= vim.NIL end
+local function uri_key(uri)
+ local path = vim.fs.normalize(vim.uri_to_fname(uri)):gsub('\\', '/')
+ return vim.fn.has('win32') == 1 and path:lower() or path
+end
+local function text(buf) return table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), '\n') .. '\n' end
+local function scenario(name, fn)
+ phase = name
+ fn()
+ table.insert(result.cases, name)
+end
+local function wait_until(fn, message)
+ assert(vim.wait(15000, fn, 20), message)
+end
+local function open(name)
+ vim.cmd.edit(vim.fn.fnameescape(config.files[name].path))
+ local buf = vim.api.nvim_get_current_buf()
+ vim.bo[buf].filetype = name:match('%.tsx$') and 'typescriptreact' or 'typescript'
+ return buf
+end
+local function set_source(buf, source)
+ local lines = vim.split(source:gsub('\n$', ''), '\n', { plain = true })
+ vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
+end
+local function offset(buf, marker)
+ local source = text(buf)
+ local comment = '/*' .. marker .. '*/'
+ local at = assert(source:find(comment, 1, true), 'Missing marker ' .. marker)
+ local after = at - 1 + #comment
+ while source:sub(after + 1, after + 1):match('%s') do after = after + 1 end
+ return after
+end
+local function position(buf, at)
+ local source = text(buf)
+ local prefix = source:sub(1, at)
+ local _, line = prefix:gsub('\n', '\n')
+ local line_start = (prefix:match('.*()\n') or 0)
+ return { line = line, character = vim.str_utfindex(source:sub(line_start + 1):match('^[^\n]*'), 'utf-16', at - line_start) }
+end
+local function params(buf, at)
+ return { textDocument = { uri = vim.uri_from_bufnr(buf) }, position = position(buf, at) }
+end
+local function request(client, method, buf, at, extra)
+ local arguments = vim.tbl_extend('force', params(buf, at), extra or {})
+ local response = assert(client:request_sync(method, arguments, 15000, buf), method .. ' timed out')
+ assert(not response.err, method .. ': ' .. vim.inspect(response.err))
+ return response.result
+end
+local function diagnostic_count(client, buf)
+ return (diagnostic_counts[client.id] or {})[uri_key(vim.uri_from_bufnr(buf))] or 0
+end
+local function wait_diagnostics(client, buf, before, predicate, version)
+ local uri = uri_key(vim.uri_from_bufnr(buf))
+ wait_until(function()
+ local values = (diagnostics[client.id] or {})[uri]
+ return diagnostic_count(client, buf) > before and values
+ and (version == nil or values.version == version) and predicate(values.diagnostics)
+ end, 'Fresh expected diagnostics missing for ' .. client.name .. ': ' .. vim.inspect(diagnostics[client.id]))
+end
+local function attach(client, buf) assert(vim.lsp.buf_attach_client(buf, client.id)) end
+local function start(name, buf, versioned)
+ local capabilities = vim.lsp.protocol.make_client_capabilities()
+ capabilities.general.positionEncodings = { 'utf-16' }
+ if versioned then capabilities.workspace.workspaceEdit.documentChanges = true end
+ local command, init_options
+ if name == 'typescript' then
+ command = { config.node, config.typescriptCli, '--stdio' }
+ init_options = { disableAutomaticTypingAcquisition = true, hostInfo = 'SharpTS Neovim editing smoke',
+ tsserver = { path = config.tsserver, useSyntaxServer = 'never', logDirectory = config.workspace .. '/tsserver-logs', logVerbosity = 'normal' } }
+ else
+ command = { config.dotnet, config.server, '--language-features', config.mode == 'full' and 'full' or 'interop-only',
+ '--diagnostics', config.mode == 'full' and 'all' or 'sharpts-only' }
+ end
+ vim.api.nvim_set_current_buf(buf)
+ local id = assert(vim.lsp.start({ name = name, cmd = command, root_dir = config.workspace, capabilities = capabilities,
+ init_options = init_options, flags = { debounce_text_changes = 20 },
+ handlers = {
+ ['textDocument/publishDiagnostics'] = function(err, notification, ctx, options)
+ diagnostics[ctx.client_id] = diagnostics[ctx.client_id] or {}
+ diagnostic_counts[ctx.client_id] = diagnostic_counts[ctx.client_id] or {}
+ local key = uri_key(notification.uri)
+ diagnostics[ctx.client_id][key] = notification
+ diagnostic_counts[ctx.client_id][key] = (diagnostic_counts[ctx.client_id][key] or 0) + 1
+ if default_diagnostics then default_diagnostics(err, notification, ctx, options) end
+ end,
+ ['$/typescriptVersion'] = function(_, notification) result.typescriptBackend = notification end,
+ },
+ }, { reuse_client = function() return false end }))
+ clients[id] = true
+ local client = assert(vim.lsp.get_client_by_id(id))
+ wait_until(function() return client.initialized end, name .. ' initialize timed out')
+ table.insert(result.clients, { id = id, name = name, command = command, capabilities = capabilities,
+ serverCapabilities = client.server_capabilities, offsetEncoding = client.offset_encoding })
+ assert(client.offset_encoding == 'utf-16', 'Exact UTF-16 negotiation required')
+ return client
+end
+local function stop(client)
+ client:stop()
+ wait_until(function() return client:is_stopped() end, 'Server failed graceful stop: ' .. client.name)
+end
+local function list(value)
+ if not present(value) then return {} end
+ if value.uri or value.targetUri then return { value } end
+ return value.items or value
+end
+local function hover(client, buf, marker, spelling)
+ local value = request(client, 'textDocument/hover', buf, offset(buf, marker) + 1)
+ assert(present(value), 'Expected hover from ' .. client.name)
+ local encoded = vim.json.encode(value)
+ assert(encoded:find(spelling, 1, true), 'Hover missing ' .. spelling .. ': ' .. encoded)
+ return value
+end
+local function expect_definition(client, buf, marker, dependency_text)
+ local values = list(request(client, 'textDocument/definition', buf, offset(buf, marker) + 1))
+ assert(#values == 1, vim.inspect(values))
+ local expected_at = assert(dependency_text:find('/*depDeclaration*/', 1, true)) - 1 + #'/*depDeclaration*/'
+ local prefix = dependency_text:sub(1, expected_at)
+ local _, line = prefix:gsub('\n', '\n')
+ local line_start = prefix:match('.*()\n') or 0
+ local character = vim.str_utfindex(dependency_text:sub(line_start + 1):match('^[^\n]*'), 'utf-16', expected_at - line_start)
+ local target_uri = values[1].uri or values[1].targetUri
+ local target_range = values[1].range or values[1].targetSelectionRange
+ local function canonical(path)
+ local normalized = vim.fs.normalize(path):gsub('\\', '/')
+ return vim.fn.has('win32') == 1 and normalized:lower() or normalized
+ end
+ assert(canonical(vim.uri_to_fname(target_uri)) == canonical(config.files['dep.ts'].path), vim.inspect(values))
+ assert(target_range.start.line == line and target_range.start.character == character, vim.inspect(values))
+ return values
+end
+local function native_hover(buf, at, expected)
+ vim.api.nvim_set_current_buf(buf)
+ local source = text(buf)
+ local prefix = source:sub(1, at)
+ local _, line = prefix:gsub('\n', '\n')
+ local line_start = prefix:match('.*()\n') or 0
+ vim.api.nvim_win_set_cursor(0, { line + 1, at - line_start })
+ vim.lsp.buf.hover({ silent = true })
+ local rendered
+ wait_until(function()
+ for _, win in ipairs(vim.api.nvim_list_wins()) do
+ if vim.api.nvim_win_get_config(win).relative ~= '' then
+ local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(win), 0, -1, false)
+ local contents = table.concat(lines, '\n')
+ if contents:find(expected, 1, true) then rendered = contents; return true end
+ end
+ end
+ return false
+ end, 'Native hover UI missing ' .. expected)
+ for _, win in ipairs(vim.api.nvim_list_wins()) do
+ if vim.api.nvim_win_get_config(win).relative ~= '' then vim.api.nvim_win_close(win, true) end
+ end
+ return rendered
+end
+local function save_and_verify(client, buf, name, marker, expected_hover)
+ vim.api.nvim_set_current_buf(buf)
+ set_source(buf, config.files[name].source .. ' ')
+ assert(vim.bo[buf].modified)
+ hover(client, buf, marker, expected_hover)
+ local before = diagnostic_count(client, buf)
+ vim.cmd.write()
+ local file = assert(io.open(config.files[name].path, 'rb'))
+ local actual = file:read('*a'); file:close()
+ assert(actual == config.files[name].formatted, 'Format-on-save differs from pinned Prettier: ' .. name)
+ if client.name ~= 'typescript' then
+ wait_diagnostics(client, buf, before, function() return true end, vim.lsp.util.buf_versions[buf])
+ end
+ hover(client, buf, marker, expected_hover)
+end
+
+local function run()
+ dofile(config.recipe)
+ local main = open('main.ts')
+ local sharp = start('sharpts', main)
+ assert(not sharp:supports_method('textDocument/formatting', main))
+ if config.mode == 'full' then
+ scenario('full-native-hover-definition-references', function()
+ hover(sharp, main, 'member', 'number')
+ expect_definition(sharp, main, 'member', config.files['dep.ts'].source)
+ local refs = list(request(sharp, 'textDocument/references', main, offset(main, 'member') + 1,
+ { context = { includeDeclaration = true } }))
+ assert(#refs == 3, vim.inspect(refs))
+ local expected = position(main, offset(main, 'member'))
+ local main_ref
+ for _, location in ipairs(refs) do
+ if uri_key(location.uri) == uri_key(vim.uri_from_bufnr(main)) then main_ref = location end
+ end
+ assert(main_ref and main_ref.range.start.line == expected.line
+ and main_ref.range.start.character == expected.character
+ and main_ref.range['end'].character == expected.character + #'field', vim.inspect(refs))
+ result.observations.memberReferences = refs
+ result.nativeOrdinaryHover = native_hover(main, offset(main, 'member') + 1, 'number')
+ end)
+ scenario('unfinished-receiver-completion-applied-by-native-client', function()
+ local header = "import { Model, pair } from './dep';\nconst item = new Model();\n"
+ set_source(main, header .. 'item.')
+ local completions = list(request(sharp, 'textDocument/completion', main, #text(main) - 1))
+ local chosen
+ for _, item in ipairs(completions) do if item.label == 'field' then chosen = item end end
+ assert(chosen and chosen.textEdit, vim.inspect(completions))
+ result.observations.appliedCompletion = chosen
+ vim.lsp.util.apply_text_edits({ chosen.textEdit }, main, sharp.offset_encoding)
+ assert(text(main):find('item.field', 1, true))
+ local at = assert(text(main):find('item.field', 1, true)) - 1 + #'item.'
+ local fresh = request(sharp, 'textDocument/hover', main, at + 1)
+ assert(present(fresh) and vim.json.encode(fresh):find('number', 1, true))
+ set_source(main, config.files['main.ts'].source)
+ end)
+ scenario('unfinished-call-signatures-without-a-false-winner', function()
+ set_source(main, "import { pair } from './dep';\npair(1, ")
+ local help = request(sharp, 'textDocument/signatureHelp', main, #text(main) - 1,
+ { context = { triggerKind = 2, triggerCharacter = ',', isRetrigger = true } })
+ assert(present(help) and #help.signatures == 1, vim.inspect(help))
+ assert(help.signatures[1].label:find('count: number', 1, true), vim.inspect(help))
+ assert(help.activeParameter == 1 and not present(help.activeSignature), vim.inspect(help))
+ result.observations.unfinishedSignature = help
+ set_source(main, config.files['main.ts'].source)
+ end)
+ scenario('native-lexical-workspace-edit-and-fresh-hover', function()
+ local prepared = request(sharp, 'textDocument/prepareRename', main, offset(main, 'lexicalUse') + 1)
+ assert(present(prepared))
+ local edit = request(sharp, 'textDocument/rename', main, offset(main, 'lexicalUse') + 1, { newName = 'renamedLocal' })
+ assert(present(edit) and edit.changes)
+ vim.lsp.util.apply_workspace_edit(edit, sharp.offset_encoding)
+ assert(text(main):find('/*lexicalUse*/renamedLocal', 1, true))
+ hover(sharp, main, 'lexicalUse', 'renamedLocal: 1')
+ set_source(main, config.files['main.ts'].source)
+ end)
+ scenario('dirty-dependency-diagnostics-target-ranges-and-close', function()
+ local dependency = open('dep.ts'); attach(sharp, dependency)
+ local before = diagnostic_count(sharp, main)
+ local dirty = '// dirty dependency shifted\n' .. config.files['dep.ts'].source:gsub('field', 'changedField')
+ set_source(dependency, dirty)
+ request(sharp, 'textDocument/hover', dependency, offset(dependency, 'depDeclaration') + 1)
+ request(sharp, 'textDocument/hover', main, offset(main, 'member') + 1)
+ wait_diagnostics(sharp, main, before, function(values) return #values > 0 end)
+ set_source(main, config.files['main.ts'].source:gsub('field', 'changedField'))
+ hover(sharp, main, 'member', 'number')
+ result.observations.dirtyDependencyDefinition = expect_definition(sharp, main, 'member', dirty)
+ vim.api.nvim_buf_delete(dependency, { force = true })
+ before = diagnostic_count(sharp, main)
+ set_source(main, config.files['main.ts'].source)
+ hover(sharp, main, 'member', 'number')
+ wait_diagnostics(sharp, main, before, function(values) return #values == 0 end)
+ expect_definition(sharp, main, 'member', config.files['dep.ts'].source)
+ end)
+ scenario('ts-and-tsx-one-save-formatter-with-fresh-language-requests', function()
+ save_and_verify(sharp, main, 'main.ts', 'member', 'number')
+ local view = open('view.tsx'); attach(sharp, view)
+ save_and_verify(sharp, view, 'view.tsx', 'tsxUse', 'number')
+ end)
+ local private = open('private.ts'); attach(sharp, private)
+ scenario('native-default-capabilities-refuse-private-edits', function()
+ assert(sharp.config.capabilities.workspace.workspaceEdit.documentChanges ~= true)
+ assert(not present(request(sharp, 'textDocument/prepareRename', private, offset(private, 'privateUse') + 1)))
+ assert(not present(request(sharp, 'textDocument/rename', private, offset(private, 'privateUse') + 1, { newName = 'new' })))
+ result.defaultPrivateRename = 'refused: documentChanges is absent from native capabilities'
+ end)
+ stop(sharp)
+ sharp = start('sharpts-versioned-private', private, true)
+ scenario('explicit-versioned-capability-applies-private-domain-natively', function()
+ local prepared = request(sharp, 'textDocument/prepareRename', private, offset(private, 'privateUse') + 1)
+ assert(present(prepared) and prepared.placeholder == '#old', vim.inspect(prepared))
+ local at = position(private, offset(private, 'privateUse'))
+ assert(prepared.range.start.line == at.line and prepared.range.start.character == at.character)
+ assert(prepared.range['end'].character - prepared.range.start.character == 4)
+ local edit = request(sharp, 'textDocument/rename', private, offset(private, 'privateUse') + 1, { newName = '#new' })
+ assert(present(edit) and not present(edit.changes) and #edit.documentChanges == 1, vim.inspect(edit))
+ assert(type(edit.documentChanges[1].textDocument.version) == 'number')
+ assert(#edit.documentChanges[1].edits == 2)
+ vim.lsp.util.apply_workspace_edit(edit, sharp.offset_encoding)
+ assert(text(private):find('/*privateUse*/#new', 1, true))
+ assert(text(private):find('class Other { #old', 1, true))
+ local rebound = request(sharp, 'textDocument/prepareRename', private, offset(private, 'privateUse') + 1)
+ assert(present(rebound) and rebound.placeholder == '#new')
+ result.versionedPrivateRename = { applied = true, version = edit.documentChanges[1].textDocument.version }
+ result.observations.appliedPrivateEdit = edit
+ end)
+ else
+ local ts = start('typescript', main)
+ scenario('two-real-servers-have-one-ordinary-navigation-owner', function()
+ assert(not sharp:supports_method('textDocument/definition', main))
+ assert(not sharp:supports_method('textDocument/references', main))
+ assert(not sharp:supports_method('textDocument/rename', main))
+ assert(ts:supports_method('textDocument/definition', main))
+ assert(not present(request(sharp, 'textDocument/hover', main, offset(main, 'member') + 1)))
+ hover(ts, main, 'member', 'number')
+ expect_definition(ts, main, 'member', config.files['dep.ts'].source)
+ local all
+ vim.lsp.buf_request_all(main, 'textDocument/definition', params(main, offset(main, 'member') + 1),
+ function(values) all = values end)
+ wait_until(function() return all ~= nil end, 'Native multi-client definition did not finish')
+ assert(vim.tbl_count(all) == 1 and all[ts.id], vim.inspect(all))
+ result.ordinaryNavigationOwner = ts.name
+ result.nativeOrdinaryHover = native_hover(main, offset(main, 'member') + 1, 'number')
+ end)
+ scenario('ordinary-diagnostics-have-only-the-typescript-owner', function()
+ local sharp_before, ts_before = diagnostic_count(sharp, main), diagnostic_count(ts, main)
+ set_source(main, config.files['main.ts'].source .. 'const mismatch: number = "wrong";\n')
+ hover(ts, main, 'member', 'number')
+ request(sharp, 'textDocument/hover', main, offset(main, 'member') + 1)
+ wait_diagnostics(ts, main, ts_before, function(values)
+ for _, value in ipairs(values) do if value.message:find('assignable', 1, true) then return true end end
+ return false
+ end)
+ wait_diagnostics(sharp, main, sharp_before, function(values) return #values == 0 end)
+ result.ordinaryDiagnosticsOwner = ts.name
+ set_source(main, config.files['main.ts'].source)
+ end)
+ scenario('unique-clr-response-reaches-native-merged-hover', function()
+ local clr = open('clr.ts'); attach(sharp, clr); attach(ts, clr)
+ hover(sharp, clr, 'clr', 'System.Text.StringBuilder')
+ local ordinary = request(ts, 'textDocument/hover', clr, offset(clr, 'clr') + 1)
+ assert(not present(ordinary) or not vim.json.encode(ordinary):find('System.Text.StringBuilder', 1, true))
+ result.nativeClrHover = native_hover(clr, offset(clr, 'clr') + 1, 'System.Text.StringBuilder')
+ save_and_verify(sharp, clr, 'clr.ts', 'clr', 'System.Text.StringBuilder')
+ result.clrHoverOwner = sharp.name
+ end)
+ scenario('coexistence-ts-tsx-formatting-stays-editor-owned', function()
+ save_and_verify(ts, main, 'main.ts', 'member', 'number')
+ local view = open('view.tsx'); attach(sharp, view); attach(ts, view)
+ save_and_verify(ts, view, 'view.tsx', 'tsxUse', 'number')
+ assert(not sharp:supports_method('textDocument/formatting', view))
+ local hooks = {}
+ for _, autocmd in ipairs(vim.api.nvim_get_autocmds({ group = 'sharpts_prettier', event = 'BufWritePre' })) do
+ hooks[autocmd.id] = true
+ end
+ assert(vim.tbl_count(hooks) == 1, 'Exactly one save hook with .ts/.tsx patterns required')
+ result.formattingOwner = 'one external Prettier BufWritePre hook; no LSP formatting call'
+ end)
+ assert(result.typescriptBackend and result.typescriptBackend.version == config.typescript,
+ 'Actual tsserver must report pinned TypeScript: ' .. vim.inspect(result.typescriptBackend))
+ end
+ for name, file in pairs(config.files) do
+ local buf = vim.fn.bufnr(file.path)
+ if buf >= 0 and vim.api.nvim_buf_is_loaded(buf) then result.sources[name] = text(buf) end
+ end
+ result.completed = true
+end
+
+local ok, error = xpcall(run, debug.traceback)
+if not ok then result.completed = false; result.error = phase .. ': ' .. error end
+result.lastPhase = phase
+result.diagnostics = diagnostics
+result.diagnosticCounts = diagnostic_counts
+for id in pairs(clients) do local client = vim.lsp.get_client_by_id(id); if client then pcall(function() client:stop() end) end end
+vim.wait(5000, function()
+ for id in pairs(clients) do local client = vim.lsp.get_client_by_id(id); if client and not client:is_stopped() then return false end end
+ return true
+end, 20)
+for id in pairs(clients) do local client = vim.lsp.get_client_by_id(id); if client then pcall(function() client:stop(true) end) end end
+vim.fn.writefile({ vim.json.encode(result) }, config.result)
+if not ok then io.stderr:write(result.error .. '\n'); vim.cmd.cquit() end
diff --git a/scripts/editor-smoke/neovim/README.md b/scripts/editor-smoke/neovim/README.md
new file mode 100644
index 000000000..31f313eb6
--- /dev/null
+++ b/scripts/editor-smoke/neovim/README.md
@@ -0,0 +1,22 @@
+# Neovim format-on-save smoke
+
+Install the pinned formatter and build the language server, then run from the repository root:
+
+```powershell
+npm ci --prefix tools/formatter-interop
+dotnet build src/SharpTS.LanguageServer -c Release
+node scripts/editor-smoke/neovim/run.mjs --nvim /absolute/path/to/nvim.exe --server src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll
+```
+
+The runner uses isolated workspaces and Neovim's headless mode, extracting the actual save-hook
+recipe from `docs/formatting.md`. In both SharpTS modes it attaches the real server, checks its
+formatting/navigation capabilities, obtains .NET interop hover from dirty `.ts` and `.tsx` buffers,
+checks fresh hover again after formatting, and verifies a second dirty save against the pinned
+Prettier CLI. Logs and version-stamped results go to
+`artifacts/formatter-interop/neovim`; override that with `--artifacts`.
+
+This exercises the editor's real save autocmd and LSP client. It does not test a graphical UI,
+another TypeScript client, or arbitrary third-party Neovim formatting plugins.
+
+An empty generated GUI contract in each disposable workspace bounds the server's existing GUI
+metadata discovery; the fixture project is never built or restored.
diff --git a/scripts/editor-smoke/neovim/run.mjs b/scripts/editor-smoke/neovim/run.mjs
new file mode 100644
index 000000000..ff3468b24
--- /dev/null
+++ b/scripts/editor-smoke/neovim/run.mjs
@@ -0,0 +1,101 @@
+import assert from "node:assert/strict";
+import { spawn, spawnSync } from "node:child_process";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const directory = path.dirname(fileURLToPath(import.meta.url));
+const root = path.resolve(directory, "../../..");
+const options = new Map();
+for (let index = 2; index < process.argv.length; index += 2) {
+ assert.ok(process.argv[index]?.startsWith("--") && process.argv[index + 1], "use --name value pairs");
+ options.set(process.argv[index].slice(2), process.argv[index + 1]);
+}
+const nvim = path.resolve(root, options.get("nvim") ?? "artifacts/editor-tools/nvim-win-arm64/bin/nvim.exe");
+const server = path.resolve(root, options.get("server") ?? "src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll");
+const artifacts = path.resolve(root, options.get("artifacts") ?? "artifacts/formatter-interop/neovim");
+const prettier = path.join(root, "tools/formatter-interop/node_modules/prettier/bin/prettier.cjs");
+await fs.access(nvim);
+await fs.access(server);
+const pin = JSON.parse(await fs.readFile(path.join(root, "tools/formatter-interop/package.json"), "utf8")).devDependencies.prettier;
+const installed = spawnSync(process.execPath, [prettier, "--version"], { encoding: "utf8", windowsHide: true, timeout: 10000 });
+assert.equal(installed.status, 0, installed.stderr);
+const prettierVersion = installed.stdout.trim();
+assert.equal(prettierVersion, pin, "installed formatter differs from the exact repository pin");
+const markdown = await fs.readFile(path.join(root, "docs/formatting.md"), "utf8");
+const recipe = markdown.match(/```lua\r?\n([\s\S]*?)\r?\n```/)?.[1];
+assert.ok(recipe, "documentation must include the tested Neovim recipe");
+const results = [];
+for (const mode of ["full", "interop-only"]) {
+ const workspace = path.join(artifacts, mode);
+ await fs.mkdir(workspace, { recursive: true });
+ // Bound the existing GUI metadata discovery walk to this disposable workspace.
+ await fs.writeFile(path.join(workspace, "Smoke.csproj"), ' \n');
+ const gui = path.join(workspace, "obj/@sharpts/gui");
+ await fs.mkdir(gui, { recursive: true });
+ await fs.writeFile(path.join(gui, "control-docs.generated.json"), '{"controls":[]}\n');
+ await fs.writeFile(path.join(workspace, ".prettierrc.json"), '{"semi":true,"endOfLine":"lf"}\n');
+ const recipePath = path.join(workspace, "doc-recipe.lua");
+ await fs.writeFile(recipePath, recipe.replaceAll("/absolute/path/to/SharpTS", root.replaceAll("\\", "/")));
+ const fixtures = [];
+ for (const extension of ["ts", "tsx"]) {
+ const file = path.join(workspace, `format-on-save.${extension}`);
+ const original = '@DotNetType("System.Text.StringBuilder")\ndeclare class Builder{constructor();append(value:string):Builder;}\nconst message={text:"original",count:1};\n'
+ + (extension === "tsx" ? 'const view= ;\n' : "");
+ const edited = original.replace('text:"original"', 'text:"edited"');
+ const format = spawnSync(process.execPath, [prettier, "--stdin-filepath", file], {
+ input: edited, cwd: workspace, encoding: "utf8", timeout: 10000, windowsHide: true,
+ });
+ assert.equal(format.status, 0, format.stderr);
+ assert.notEqual(edited, format.stdout, "save fixture must require formatting");
+ await fs.writeFile(file, original);
+ fixtures.push({ file, edited, expected: format.stdout });
+ }
+ const config = {
+ mode, workspace, server, fixtures, recipe: recipePath,
+ result: path.join(workspace, "result.json"),
+ };
+ const configPath = path.join(workspace, "test-config.json");
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2));
+ await fs.rm(config.result, { force: true });
+ const child = spawn(nvim, ["--headless", "-u", "NONE", "-l", path.join(directory, "test.lua")], {
+ cwd: workspace,
+ env: {
+ ...process.env, SHARPTS_EDITOR_SMOKE_CONFIG: configPath,
+ XDG_CONFIG_HOME: path.join(workspace, "config"), XDG_DATA_HOME: path.join(workspace, "data"),
+ XDG_STATE_HOME: path.join(workspace, "state"), XDG_CACHE_HOME: path.join(workspace, "cache"),
+ NVIM_APPNAME: "sharpts-editor-smoke", NVIM_LOG_FILE: path.join(workspace, "nvim.log"),
+ },
+ windowsHide: true,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ let log = "";
+ child.stdout.on("data", (chunk) => { log += chunk; });
+ child.stderr.on("data", (chunk) => { log += chunk; });
+ const timer = setTimeout(() => {
+ if (process.platform === "win32") {
+ spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true });
+ } else child.kill("SIGKILL");
+ }, 30000);
+ let code;
+ try {
+ code = await new Promise((resolve, reject) => {
+ child.once("error", reject);
+ child.once("exit", resolve);
+ });
+ } finally { clearTimeout(timer); }
+ await fs.writeFile(path.join(workspace, "process.log"), log);
+ assert.equal(code, 0, log);
+ const result = JSON.parse(await fs.readFile(config.result, "utf8"));
+ assert.equal(result.completed, true, JSON.stringify(result));
+ for (const fixture of fixtures) {
+ assert.equal(await fs.readFile(fixture.file, "utf8"), fixture.expected, fixture.file);
+ }
+ results.push(result);
+}
+const summary = {
+ completed: true, timestamp: new Date().toISOString(), platform: `${process.platform}/${process.arch}`,
+ node: process.version, prettier: prettierVersion, results,
+};
+await fs.writeFile(path.join(artifacts, "summary.json"), JSON.stringify(summary, null, 2) + "\n");
+console.log(JSON.stringify(summary, null, 2));
diff --git a/scripts/editor-smoke/neovim/test.lua b/scripts/editor-smoke/neovim/test.lua
new file mode 100644
index 000000000..bbf66018c
--- /dev/null
+++ b/scripts/editor-smoke/neovim/test.lua
@@ -0,0 +1,98 @@
+local config = vim.json.decode(table.concat(vim.fn.readfile(assert(vim.env.SHARPTS_EDITOR_SMOKE_CONFIG)), "\n"))
+local clients = {}
+local result = { mode = config.mode, editor = "Neovim " .. tostring(vim.version()), fixtures = {} }
+result.log = vim.lsp.log.get_filename()
+vim.fn.writefile({}, result.log)
+vim.lsp.log.set_level("debug")
+
+local function run()
+ dofile(config.recipe)
+ for _, fixture in ipairs(config.fixtures) do
+ vim.cmd.edit(vim.fn.fnameescape(fixture.file))
+ local bufnr = vim.api.nvim_get_current_buf()
+ vim.bo[bufnr].filetype = fixture.file:match("%.tsx$") and "typescriptreact" or "typescript"
+ local id = assert(vim.lsp.start({
+ name = "sharpts-editor-smoke",
+ cmd = { "dotnet", config.server, "--language-features", config.mode, "--diagnostics", "sharpts-only" },
+ root_dir = config.workspace,
+ }))
+ clients[id] = true
+ local client = assert(vim.lsp.get_client_by_id(id))
+ assert(vim.wait(10000, function() return client.initialized end, 20), "LSP initialize timed out")
+ local caps = client.server_capabilities
+ assert(not caps.documentFormattingProvider, "SharpTS advertised document formatting")
+ assert(not caps.documentRangeFormattingProvider, "SharpTS advertised range formatting")
+ assert(not caps.documentOnTypeFormattingProvider, "SharpTS advertised on-type formatting")
+ if config.mode == "full" then
+ assert(vim.wait(5000, function()
+ return client:supports_method("textDocument/definition", bufnr)
+ end, 20), "full-mode definition registration timed out")
+ else
+ assert(not client:supports_method("textDocument/definition", bufnr), "interop-only registered definition")
+ end
+ assert(not client:supports_method("textDocument/formatting", bufnr), "SharpTS registered formatting")
+ assert(not client:supports_method("textDocument/rangeFormatting", bufnr), "SharpTS registered range formatting")
+ assert(not client:supports_method("textDocument/onTypeFormatting", bufnr), "SharpTS registered on-type formatting")
+ local lines = vim.split(fixture.edited:gsub("\n$", ""), "\n", { plain = true })
+ vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
+ assert(vim.bo[bufnr].modified, "fixture must have an unsaved edit")
+ local function hover()
+ local response = assert(client:request_sync("textDocument/hover", {
+ textDocument = { uri = vim.uri_from_fname(fixture.file) },
+ position = { line = 0, character = 5 },
+ }, 5000, bufnr))
+ assert(not response.err, vim.inspect(response.err))
+ local text = vim.json.encode(response.result)
+ assert(text:find("System.Text.StringBuilder", 1, true), "interop hover did not resolve")
+ assert(text:find("Represents a mutable string of characters", 1, true), "CLR documentation missing")
+ return response.result
+ end
+ local function save()
+ vim.cmd.write()
+ local file = assert(io.open(fixture.file, "rb"))
+ local actual = file:read("*a")
+ file:close()
+ assert(actual == fixture.expected, "format-on-save bytes differ from pinned Prettier: " .. fixture.file)
+ end
+ local before = hover()
+ save()
+ local after = hover()
+ local formatted = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
+ formatted[#formatted] = formatted[#formatted] .. " "
+ vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, formatted)
+ assert(vim.bo[bufnr].modified, "second save must include a dirty edit")
+ save()
+ local afterSecond = hover()
+ table.insert(result.fixtures, {
+ file = fixture.file, hoverBefore = before, hoverAfter = after,
+ hoverAfterSecondSave = afterSecond, formatted = true, secondSaveStable = true,
+ })
+ end
+ result.completed = true
+end
+
+local ok, err = xpcall(run, debug.traceback)
+if not ok then
+ result.completed = false
+ result.error = err
+end
+for id in pairs(clients) do
+ local client = vim.lsp.get_client_by_id(id)
+ if client then client:stop() end
+end
+vim.wait(3000, function()
+ for id in pairs(clients) do
+ local client = vim.lsp.get_client_by_id(id)
+ if client and not client:is_stopped() then return false end
+ end
+ return true
+end, 20)
+for id in pairs(clients) do
+ local client = vim.lsp.get_client_by_id(id)
+ if client then client:stop(true) end
+end
+vim.fn.writefile({ vim.json.encode(result) }, config.result)
+if not ok then
+ io.stderr:write(err .. "\n")
+ vim.cmd.cquit()
+end
diff --git a/scripts/editor-smoke/vscode-shipping/README.md b/scripts/editor-smoke/vscode-shipping/README.md
new file mode 100644
index 000000000..8ef714365
--- /dev/null
+++ b/scripts/editor-smoke/vscode-shipping/README.md
@@ -0,0 +1,25 @@
+# Shipping VS Code extension smoke
+
+This optional local smoke installs the packaged SharpTS extension into fresh VS Code user-data and extension directories. Its test extension has an empty activation function, registers no language providers, and starts no language server. The installed shipping extension starts SharpTS with its unchanged `interop-only` / `sharpts-only` defaults. VS Code's built-in TypeScript extension and the real Prettier extension remain active.
+
+The test checks an imported ordinary identifier's definition, a CLR decorator hover containing `System.Text.StringBuilder`, a reflection member hover containing `StringBuilder.Append`, and two actual dirty-document saves. Each save must match the exact pinned local Prettier output, and fresh navigation and both CLR hovers must still succeed afterward. Prettier's logs must identify the local module and pinned version on both saves. Installed server/core hashes must match the VSIX preparation manifest. The real LanguageClient trace must show six successful hover responses and reject ordinary navigation/formatting providers in both initialize and dynamic registrations.
+
+Prerequisites: .NET 10, Node 22 or later, a real VS Code installation, and an unpacked `esbenp.prettier-vscode` 12.4.0 extension directory. Acquire that extension separately through VS Code's supported installation mechanism; the runner neither downloads it nor uses a user profile. Install locked dependencies:
+
+```powershell
+npm ci --ignore-scripts --prefix extensions/vscode-sharpts
+npm ci --prefix tools/formatter-interop
+dotnet build SharpTS.sln -c Release
+dotnet publish src/SharpTS.LanguageServer/SharpTS.LanguageServer.csproj -c Release --no-build --no-restore -o artifacts/editor-shipping/vscode/published
+dotnet publish src/SharpTS.DebugAdapter/SharpTS.DebugAdapter.csproj -c Release --no-build --no-restore -o artifacts/editor-shipping/vscode/published
+node scripts/editor-smoke/vscode-shipping/prepare.mjs --binaries artifacts/editor-shipping/vscode/published
+node scripts/editor-smoke/vscode-shipping/run.mjs --prettier-extension /absolute/path/to/esbenp.prettier-vscode-12.4.0
+```
+
+`prepare.mjs` compiles the unchanged extension source, stages its locked production dependencies and published binaries, and invokes VSCE directly. It avoids npm's `prebuild` / `prepackage` hooks so build ownership stays explicit. It writes the VSIX, hashes and package log under `artifacts/editor-shipping/vscode/package` by default. A fresh staging directory prevents stale package files from carrying over.
+
+`run.mjs` accepts `--package `, `--code `, `--code-cli ` (Windows only, when auto-detection is ambiguous), `--dotnet `, `--prettier-extension ` and `--artifacts `. Arguments are `--name value` pairs. Windows defaults to the per-user stable VS Code install; other platforms default to `code` on PATH. Every run has a fresh artifact subdirectory and a 90-second deadline, with a result JSON, installation log, extension-host output and VS Code logs. On the tested Windows host, timeout cleanup stops the isolated editor process tree. The POSIX fallback kills the launched process only; descendant cleanup and the editor smoke have not been verified there.
+
+This proves actual extension activation and provider coexistence for the recorded editor/package versions. It does not claim that VS Code exposes provider attribution or that multiple providers are routed exclusively. Ordinary navigation is checked in the shipping interop mode, where SharpTS does not advertise definition support; CLR content is specific to SharpTS. The test does not cover debugger launch or change the extension's default mode.
+
+Verified on 2026-10-07 with VS Code 1.134.0 on Windows ARM64, shipping SharpTS 0.2.0 from base source `a20b07d7259043dd06a33fae34a507b443c847d8` plus the #1981 working-tree changes, built-in TypeScript extension 10.0.0, Prettier extension 12.4.0 and local Prettier 3.9.9. [Compact verification](last-verified.json) records the package, versions and actual capabilities. The default interop registrations, six fresh CLR responses, document versions 1 → 3 → 5 and two formatter-module log entries are recorded in `artifacts/editor-shipping/vscode/runs/run-1ubuPj/result.json`. The installed VSIX SHA-256 was `8a8817c21e4efeff77e3855f0e4ace2708403a7d6fb845d9f40af66214cf7c90`; the final server SHA-256 was `9dc0e3c3b4d401e833735179ee84185c460b9f2dcb2ae187f71f8e808627a40b`. Core hashes and raw logs remain alongside the result.
diff --git a/scripts/editor-smoke/vscode-shipping/extension.cjs b/scripts/editor-smoke/vscode-shipping/extension.cjs
new file mode 100644
index 000000000..3820c7218
--- /dev/null
+++ b/scripts/editor-smoke/vscode-shipping/extension.cjs
@@ -0,0 +1,2 @@
+// Evidence harness only. The installed shipping extensions own every language provider.
+exports.activate = function activate() {};
diff --git a/scripts/editor-smoke/vscode-shipping/last-verified.json b/scripts/editor-smoke/vscode-shipping/last-verified.json
new file mode 100644
index 000000000..6611db2a3
--- /dev/null
+++ b/scripts/editor-smoke/vscode-shipping/last-verified.json
@@ -0,0 +1,38 @@
+{
+ "verifiedLocalDate": "2026-10-07",
+ "status": "passed",
+ "sourceBase": "a20b07d7259043dd06a33fae34a507b443c847d8",
+ "sourceNote": "Includes the #1981 working-tree changes; binary fingerprints identify the actual installed package.",
+ "platform": "win32-arm64",
+ "editor": "VS Code 1.134.0",
+ "shippingExtension": "SharpTS 0.2.0",
+ "builtInTypeScriptExtension": "10.0.0",
+ "bundledTypeScript": "6.0.3",
+ "prettierExtension": "12.4.0",
+ "localPrettier": "3.9.9",
+ "vsixSha256": "8a8817c21e4efeff77e3855f0e4ace2708403a7d6fb845d9f40af66214cf7c90",
+ "languageServerSha256": "9dc0e3c3b4d401e833735179ee84185c460b9f2dcb2ae187f71f8e808627a40b",
+ "compilerSha256": "a2ee2949aace31ab14706d2bf2352125ed94d9fed085cb76ec7b55d95909e45b",
+ "serverMode": "interop-only",
+ "diagnostics": "sharpts-only",
+ "providersRegisteredByHarness": 0,
+ "serversSpawnedByHarness": 0,
+ "ordinaryImportedDefinition": "dep.ts",
+ "successfulFreshSharpTsHovers": 6,
+ "exactDirtySaveCycles": 2,
+ "documentVersions": [1, 3, 5],
+ "formatterModuleLogInstances": 2,
+ "actualRegisteredMethods": [
+ "textDocument/codeAction",
+ "textDocument/completion",
+ "textDocument/didChange",
+ "textDocument/didClose",
+ "textDocument/didOpen",
+ "textDocument/didSave",
+ "textDocument/hover",
+ "textDocument/signatureHelp",
+ "workspace/didChangeWatchedFiles"
+ ],
+ "ordinaryNavigationAndFormattingAdvertised": false,
+ "rawEvidence": "artifacts/editor-shipping/vscode/runs/run-1ubuPj/result.json"
+}
diff --git a/scripts/editor-smoke/vscode-shipping/package.json b/scripts/editor-smoke/vscode-shipping/package.json
new file mode 100644
index 000000000..28ac619ef
--- /dev/null
+++ b/scripts/editor-smoke/vscode-shipping/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "shipping-interop-smoke",
+ "publisher": "sharpts-tests",
+ "version": "1.0.0",
+ "private": true,
+ "engines": { "vscode": "^1.101.0" },
+ "activationEvents": ["onStartupFinished"],
+ "main": "./extension.cjs"
+}
diff --git a/scripts/editor-smoke/vscode-shipping/prepare.mjs b/scripts/editor-smoke/vscode-shipping/prepare.mjs
new file mode 100644
index 000000000..a78ccc47a
--- /dev/null
+++ b/scripts/editor-smoke/vscode-shipping/prepare.mjs
@@ -0,0 +1,48 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
+const options = new Map();
+for (let i = 2; i < process.argv.length; i += 2) {
+ assert.ok(process.argv[i]?.startsWith("--") && process.argv[i + 1], "use --name value pairs");
+ options.set(process.argv[i].slice(2), process.argv[i + 1]);
+}
+assert.ok(options.has("binaries"), "--binaries must name the coordinated published LS + DAP directory");
+const binaries = path.resolve(root, options.get("binaries"));
+const artifacts = path.resolve(root, options.get("artifacts") ?? "artifacts/editor-shipping/vscode/package");
+const extension = path.join(root, "extensions/vscode-sharpts");
+await fs.mkdir(artifacts, { recursive: true });
+const stage = await fs.mkdtemp(path.join(artifacts, "extension-"));
+for (const name of ["SharpTS.LanguageServer.dll", "SharpTS.LanguageServer.runtimeconfig.json", "SharpTS.dll", "SharpTS.DebugAdapter.dll"])
+ await fs.access(path.join(binaries, name));
+const compile = spawnSync(process.execPath, [path.join(extension, "node_modules/typescript/bin/tsc"), "-p", extension],
+ { cwd: root, encoding: "utf8", timeout: 60_000, windowsHide: true });
+assert.equal(compile.status, 0, compile.stdout + compile.stderr);
+for (const name of ["package.json", "package-lock.json", ".vscodeignore", "out"])
+ await fs.cp(path.join(extension, name), path.join(stage, name), { recursive: true, force: true });
+await fs.copyFile(path.join(root, "LICENSE"), path.join(stage, "LICENSE"));
+await fs.copyFile(path.join(root, "docs/language-server.md"), path.join(stage, "README.md"));
+// Use the exact installed production tree, without executing package acquisition/hooks here.
+const lock = JSON.parse(await fs.readFile(path.join(extension, "package-lock.json"), "utf8"));
+for (const [relative, metadata] of Object.entries(lock.packages)) {
+ if (!relative.startsWith("node_modules/") || metadata.dev) continue;
+ assert.ok(!relative.split("/").includes(".."));
+ await fs.cp(path.join(extension, relative), path.join(stage, relative), { recursive: true });
+}
+await fs.cp(binaries, path.join(stage, "bin/server"), { recursive: true, force: true });
+const vsix = path.join(artifacts, "vscode-sharpts.vsix");
+const packed = spawnSync(process.execPath, [path.join(extension, "node_modules/@vscode/vsce/vsce"),
+ "package", "--out", vsix], { cwd: stage, encoding: "utf8", timeout: 60_000, windowsHide: true });
+await fs.writeFile(path.join(artifacts, "package.log"), packed.stdout + packed.stderr);
+assert.equal(packed.status, 0, packed.stdout + packed.stderr);
+const hash = async (file) => createHash("sha256").update(await fs.readFile(file)).digest("hex");
+const version = JSON.parse(await fs.readFile(path.join(stage, "package.json"), "utf8")).version;
+const report = { vsix, version, sha256: await hash(vsix), binaries,
+ serverSha256: await hash(path.join(binaries, "SharpTS.LanguageServer.dll")),
+ coreSha256: await hash(path.join(binaries, "SharpTS.dll")), stage };
+await fs.writeFile(path.join(artifacts, "package.json"), JSON.stringify(report, null, 2) + "\n");
+console.log(JSON.stringify(report, null, 2));
diff --git a/scripts/editor-smoke/vscode-shipping/run.mjs b/scripts/editor-smoke/vscode-shipping/run.mjs
new file mode 100644
index 000000000..54cbbae16
--- /dev/null
+++ b/scripts/editor-smoke/vscode-shipping/run.mjs
@@ -0,0 +1,167 @@
+import assert from "node:assert/strict";
+import { spawn, spawnSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const script = path.dirname(fileURLToPath(import.meta.url));
+const root = path.resolve(script, "../../..");
+const values = new Map();
+for (let i = 2; i < process.argv.length; i += 2) {
+ assert.ok(process.argv[i]?.startsWith("--") && process.argv[i + 1], "use --name value pairs");
+ values.set(process.argv[i].slice(2), process.argv[i + 1]);
+}
+const packageFile = path.resolve(root, values.get("package") ?? "artifacts/editor-shipping/vscode/package/package.json");
+const packaged = JSON.parse(await fs.readFile(packageFile, "utf8"));
+const hash = async (file) => createHash("sha256").update(await fs.readFile(file)).digest("hex");
+assert.equal(await hash(packaged.vsix), packaged.sha256);
+const code = values.get("code") ?? (process.platform === "win32"
+ ? path.join(process.env.LOCALAPPDATA, "Programs/Microsoft VS Code/Code.exe") : "code");
+let cli = values.get("code-cli");
+if (process.platform === "win32" && !cli) {
+ const install = path.dirname(code);
+ const candidates = [path.join(install, "resources/app/out/cli.js")];
+ for (const entry of await fs.readdir(install, { withFileTypes: true }))
+ if (entry.isDirectory()) candidates.push(path.join(install, entry.name, "resources/app/out/cli.js"));
+ const existing = [];
+ for (const candidate of candidates) try { await fs.access(candidate); existing.push(candidate); } catch {}
+ assert.equal(existing.length, 1, "use --code-cli for an ambiguous VS Code install");
+ cli = existing[0];
+}
+const base = path.resolve(root, values.get("artifacts") ?? "artifacts/editor-shipping/vscode/runs");
+await fs.mkdir(base, { recursive: true });
+const artifacts = await fs.mkdtemp(path.join(base, "run-"));
+const userData = path.join(artifacts, "user-data");
+const extensions = path.join(artifacts, "extensions");
+await fs.mkdir(extensions, { recursive: true });
+const prettierExtension = path.resolve(root, values.get("prettier-extension")
+ ?? "artifacts/formatter-interop/vscode/extensions/esbenp.prettier-vscode-12.4.0");
+const prettierManifest = JSON.parse(await fs.readFile(path.join(prettierExtension, "package.json"), "utf8"));
+assert.equal(prettierManifest.publisher + "." + prettierManifest.name, "esbenp.prettier-vscode");
+assert.equal(prettierManifest.version, "12.4.0");
+await fs.cp(prettierExtension, path.join(extensions, `esbenp.prettier-vscode-${prettierManifest.version}`), { recursive: true });
+// Populate the fresh extension directory before the CLI creates its profile's
+// installed-extension inventory. Later directory copies are not auto-installed.
+const installArgs = ["--user-data-dir", userData, "--extensions-dir", extensions,
+ "--install-extension", packaged.vsix, "--force"];
+const installed = spawnSync(code, cli ? [cli, ...installArgs] : installArgs,
+ { env: cli ? { ...process.env, ELECTRON_RUN_AS_NODE: "1" } : process.env,
+ encoding: "utf8", timeout: 60_000, windowsHide: true });
+await fs.writeFile(path.join(artifacts, "install.log"), installed.stdout + installed.stderr);
+assert.equal(installed.status, 0, installed.stdout + installed.stderr);
+const formatterManifest = JSON.parse(await fs.readFile(path.join(root, "tools/formatter-interop/package.json"), "utf8"));
+const source = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", windowsHide: true });
+assert.equal(source.status, 0, source.stderr);
+const config = { root, workspace: path.join(artifacts, "workspace"),
+ fixture: path.join(artifacts, "workspace/main.ts"), dependency: path.join(artifacts, "workspace/dep.ts"),
+ result: path.join(artifacts, "extension-result.json"), package: packaged,
+ dotnet: values.get("dotnet") ?? "dotnet", sourceCommit: source.stdout.trim(),
+ prettierPath: path.join(root, "tools/formatter-interop/node_modules/prettier"),
+ prettierVersion: formatterManifest.devDependencies.prettier, prettierExtensionVersion: prettierManifest.version };
+await fs.mkdir(path.join(config.workspace, ".vscode"), { recursive: true });
+await fs.writeFile(config.fixture, 'import { ordinary } from "./dep";\n@DotNetType("System.Text.StringBuilder")\ndeclare class Builder{constructor();Append(value:string):Builder;}\nconst message={text:"hello",count:1};\nconst ordinaryUse=ordinary;\n');
+await fs.writeFile(config.dependency, "export const ordinary: number = 123;\n");
+await fs.writeFile(path.join(config.workspace, "tsconfig.json"), JSON.stringify({ compilerOptions: { experimentalDecorators: true, noEmit: true } }) + "\n");
+await fs.writeFile(path.join(config.workspace, ".prettierrc.json"), JSON.stringify({ semi: true, singleQuote: false, endOfLine: "lf" }) + "\n");
+await fs.writeFile(path.join(config.workspace, ".vscode/settings.json"), JSON.stringify({
+ "sharpts.dotnetPath": config.dotnet, "sharpts.trace.server": "verbose",
+ "editor.formatOnSave": true, "editor.formatOnSaveMode": "file", "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.codeActionsOnSave": {}, "prettier.prettierPath": config.prettierPath, "prettier.requireConfig": true,
+ "typescript.format.enable": false, "files.autoSave": "off", "security.workspace.trust.enabled": false,
+ "update.mode": "none", "extensions.autoCheckUpdates": false, "extensions.autoUpdate": false,
+ "telemetry.telemetryLevel": "off",
+}, null, 2) + "\n");
+const configFile = path.join(artifacts, "test-config.json");
+await fs.writeFile(configFile, JSON.stringify(config, null, 2) + "\n");
+const env = { ...process.env, SHARPTS_SHIPPING_SMOKE_CONFIG: configFile };
+delete env.ELECTRON_RUN_AS_NODE;
+const child = spawn(code, ["--user-data-dir", userData, "--extensions-dir", extensions,
+ "--extensionDevelopmentPath", script, "--extensionTestsPath", path.join(script, "test.cjs"),
+ "--disable-workspace-trust", "--skip-welcome", "--skip-release-notes", "--disable-updates",
+ "--disable-telemetry", "--disable-gpu", "--new-window", config.workspace],
+ { cwd: root, env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
+let log = "";
+child.stdout.on("data", (chunk) => { log += chunk; });
+child.stderr.on("data", (chunk) => { log += chunk; });
+let timedOut = false;
+const timer = setTimeout(() => {
+ timedOut = true;
+ if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true });
+ else child.kill("SIGKILL");
+}, 90_000);
+const exitCode = await new Promise((resolve, reject) => { child.once("error", reject); child.once("exit", resolve); })
+ .finally(() => clearTimeout(timer));
+await fs.writeFile(path.join(artifacts, "extension-host.log"), log);
+assert.equal(timedOut, false, `VS Code exceeded 90 seconds; see ${artifacts}`);
+assert.equal(exitCode, 0, `VS Code failed; see ${artifacts}/extension-host.log`);
+const result = JSON.parse(await fs.readFile(config.result, "utf8"));
+assert.equal(result.status, "passed");
+async function logFiles(directory) {
+ const files = [];
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
+ const target = path.join(directory, entry.name);
+ if (entry.isDirectory()) files.push(...await logFiles(target));
+ else if (entry.isFile() && entry.name.endsWith(".log")) files.push(target);
+ }
+ return files;
+}
+const logs = await logFiles(path.join(userData, "logs"));
+const normalize = (value) => process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value);
+let instances = 0;
+const formatterLogs = [];
+for (const file of logs.filter((file) => file.endsWith("-Prettier.log"))) {
+ const text = await fs.readFile(file, "utf8");
+ for (const match of text.matchAll(/PrettierInstance:\r?\n(\{[\s\S]*?\r?\n\})/g)) {
+ const instance = JSON.parse(match[1]);
+ assert.equal(instance.version, config.prettierVersion);
+ assert.equal(normalize(instance.modulePath), normalize(config.prettierPath));
+ instances++;
+ }
+ formatterLogs.push(file);
+}
+assert.ok(instances >= 2, "both saves must log the pinned local formatter instance");
+// Parse real LanguageClient trace JSON, including dynamic registration. VS Code
+// advertises dynamic support, so initialize alone does not describe its providers.
+function payload(text, start, prefix) {
+ const from = text.indexOf(prefix, start);
+ assert.ok(from >= 0, `missing trace ${prefix}`);
+ const begin = from + prefix.length;
+ let depth = 0, quoted = false, escaped = false;
+ for (let index = begin; index < text.length; index++) {
+ const char = text[index];
+ if (quoted) {
+ if (escaped) escaped = false;
+ else if (char === "\\") escaped = true;
+ else if (char === '"') quoted = false;
+ } else if (char === '"') quoted = true;
+ else if (char === "{" || char === "[") depth++;
+ else if ((char === "}" || char === "]") && --depth === 0)
+ return JSON.parse(text.slice(begin, index + 1));
+ }
+ assert.fail("unterminated trace JSON");
+}
+const serverLogs = logs.filter((file) => file.endsWith("-SharpTS Language Server.log"));
+assert.equal(serverLogs.length, 1, "exactly one shipping SharpTS LanguageClient must be active");
+const trace = await fs.readFile(serverLogs[0], "utf8");
+const initialize = trace.indexOf("Received response 'initialize - (0)'");
+assert.ok(initialize >= 0, "shipping client must initialize its installed server");
+const initialized = payload(trace, initialize, "Result: ");
+const registeredMethods = [];
+for (const match of trace.matchAll(/Received request 'client\/registerCapability - \(\d+\)'/g))
+ registeredMethods.push(...payload(trace, match.index, "Params: ").registrations.map((item) => item.method));
+for (const method of ["textDocument/hover", "textDocument/completion", "textDocument/signatureHelp"])
+ assert.ok(registeredMethods.includes(method), `shipping interop client must register ${method}`);
+for (const provider of ["definitionProvider", "referencesProvider", "renameProvider", "documentSymbolProvider",
+ "documentFormattingProvider", "documentRangeFormattingProvider", "documentOnTypeFormattingProvider"])
+ assert.ok(!initialized.capabilities[provider], `shipping mode must not advertise ${provider}`);
+assert.ok(!registeredMethods.some((method) => /^textDocument\/(definition|references|rename|documentSymbol|formatting|rangeFormatting|onTypeFormatting)$/.test(method)),
+ "shipping client must not dynamically claim ordinary navigation or formatting");
+const successfulHovers = [...trace.matchAll(/Received response 'textDocument\/hover - \(\d+\)'[^\n]*\r?\nResult: /g)].length;
+assert.ok(successfulHovers >= 6, "decorator and CLR member hovers must obtain fresh real server responses across both saves");
+Object.assign(result, { formatterModuleVerified: true, formatterInstances: instances, formatterLogs,
+ serverInfo: initialized.serverInfo, initializedCapabilities: initialized.capabilities,
+ registeredMethods: [...new Set(registeredMethods)].sort(), successfulSharpTsHovers: successfulHovers, serverLog: serverLogs[0],
+ artifacts, installLog: path.join(artifacts, "install.log"), editorLogFiles: logs });
+await fs.writeFile(path.join(artifacts, "result.json"), JSON.stringify(result, null, 2) + "\n");
+console.log(JSON.stringify(result, null, 2));
diff --git a/scripts/editor-smoke/vscode-shipping/test.cjs b/scripts/editor-smoke/vscode-shipping/test.cjs
new file mode 100644
index 000000000..99190311e
--- /dev/null
+++ b/scripts/editor-smoke/vscode-shipping/test.cjs
@@ -0,0 +1,93 @@
+const assert = require("node:assert/strict");
+const { createHash } = require("node:crypto");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+const vscode = require("vscode");
+
+// No provider registrations and no server process in this extension. The installed
+// shipping extension is solely responsible for starting SharpTS's real client.
+exports.run = async function run() {
+ const config = JSON.parse(await fs.readFile(process.env.SHARPTS_SHIPPING_SMOKE_CONFIG, "utf8"));
+ const extensions = {};
+ for (const id of ["sharpts.vscode-sharpts", "vscode.typescript-language-features", "esbenp.prettier-vscode"]) {
+ const extension = vscode.extensions.getExtension(id);
+ assert.ok(extension, `installed extension ${id} must be available`);
+ await extension.activate();
+ assert.equal(extension.isActive, true, `${id} must activate`);
+ extensions[id] = { version: extension.packageJSON.version, path: extension.extensionPath };
+ }
+ const shippingPath = extensions["sharpts.vscode-sharpts"].path;
+ const hash = async (file) => createHash("sha256").update(await fs.readFile(file)).digest("hex");
+ assert.equal(await hash(path.join(shippingPath, "bin/server/SharpTS.LanguageServer.dll")), config.package.serverSha256);
+ assert.equal(await hash(path.join(shippingPath, "bin/server/SharpTS.dll")), config.package.coreSha256);
+ assert.equal(extensions["sharpts.vscode-sharpts"].version, config.package.version);
+ const prettier = require(config.prettierPath);
+ assert.equal(prettier.version, config.prettierVersion);
+ assert.equal(extensions["esbenp.prettier-vscode"].version, config.prettierExtensionVersion);
+ const uri = vscode.Uri.file(config.fixture);
+ const document = await vscode.workspace.openTextDocument(uri);
+ await vscode.window.showTextDocument(document);
+ assert.equal(document.languageId, "typescript");
+ assert.equal(vscode.workspace.getConfiguration("sharpts").get("diagnostics"), "sharpts-only");
+ assert.equal(vscode.workspace.getConfiguration("editor", uri).get("defaultFormatter"), "esbenp.prettier-vscode");
+ assert.equal(vscode.workspace.getConfiguration("editor", uri).get("formatOnSave"), true);
+ const observations = [];
+ const hoverText = (items) => (items ?? []).flatMap((hover) => hover.contents)
+ .map((content) => typeof content === "string" ? content : content.value);
+ const samePath = (left, right) => process.platform === "win32"
+ ? path.resolve(left).toLowerCase() === path.resolve(right).toLowerCase()
+ : path.resolve(left) === path.resolve(right);
+ async function until(description, query, predicate) {
+ const deadline = Date.now() + 15_000;
+ let value;
+ do {
+ value = await query();
+ if (predicate(value)) return value;
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ } while (Date.now() < deadline);
+ assert.fail(`${description}: ${JSON.stringify(value)}`);
+ }
+ async function assertProviders(phase) {
+ const text = document.getText();
+ const ordinaryOffset = text.lastIndexOf("ordinary") + 2;
+ const definitions = await until("built-in TypeScript ordinary definition", () =>
+ vscode.commands.executeCommand("vscode.executeDefinitionProvider", uri, document.positionAt(ordinaryOffset)),
+ (items) => items?.some((item) => samePath((item.targetUri ?? item.uri).fsPath, config.dependency)));
+ const definition = definitions.find((item) => samePath((item.targetUri ?? item.uri).fsPath, config.dependency));
+ const hovers = await until("fresh shipping SharpTS CLR decorator hover", () =>
+ vscode.commands.executeCommand("vscode.executeHoverProvider", uri, document.positionAt(text.indexOf("DotNetType") + 3)),
+ (items) => hoverText(items).some((content) => content.includes("System.Text.StringBuilder")));
+ const memberHovers = await until("fresh shipping SharpTS CLR member hover", () =>
+ vscode.commands.executeCommand("vscode.executeHoverProvider", uri, document.positionAt(text.indexOf("Append") + 2)),
+ (items) => hoverText(items).some((content) => content.includes("StringBuilder.Append")));
+ observations.push({ phase, version: document.version,
+ definition: { uri: (definition.targetUri ?? definition.uri).toString(),
+ range: definition.targetSelectionRange ?? definition.range },
+ clrHover: hoverText(hovers)
+ .filter((content) => content.includes("System.Text.StringBuilder")),
+ clrMemberHover: hoverText(memberHovers).filter((content) => content.includes("StringBuilder.Append")) });
+ }
+ await assertProviders("before-save");
+ for (let cycle = 1; cycle <= 2; cycle++) {
+ const before = document.getText();
+ const edit = new vscode.WorkspaceEdit();
+ edit.insert(uri, document.positionAt(before.length), " ");
+ assert.equal(await vscode.workspace.applyEdit(edit), true);
+ assert.equal(document.isDirty, true, "each save must run actual dirty-document save participants");
+ const expected = await prettier.format(document.getText(), {
+ ...(await prettier.resolveConfig(config.fixture)), filepath: config.fixture,
+ });
+ assert.notEqual(document.getText(), expected, "each dirty document must require formatting");
+ assert.equal(await document.save(), true);
+ assert.equal(document.isDirty, false);
+ assert.equal(document.getText(), expected, `save ${cycle} must match pinned Prettier`);
+ assert.equal(await fs.readFile(config.fixture, "utf8"), expected);
+ await assertProviders(`after-save-${cycle}`);
+ }
+ await fs.writeFile(config.result, JSON.stringify({ status: "passed", editor: `VS Code ${vscode.version}`,
+ platform: `${process.platform}-${process.arch}`, extensions, package: config.package,
+ sourceCommit: config.sourceCommit, formatter: `Prettier ${prettier.version}`, saves: 2,
+ exactFormattedOutput: true, serverMode: "interop-only", diagnostics: "sharpts-only",
+ modeSource: "unchanged shipping extension activation arguments and default configuration",
+ providersRegisteredByHarness: 0, serversSpawnedByHarness: 0, observations }, null, 2) + "\n");
+};
diff --git a/scripts/editor-smoke/vscode/README.md b/scripts/editor-smoke/vscode/README.md
new file mode 100644
index 000000000..6a49ea0e5
--- /dev/null
+++ b/scripts/editor-smoke/vscode/README.md
@@ -0,0 +1,38 @@
+# Isolated VS Code format-on-save smoke
+
+This optional extension-host smoke uses the installed VS Code executable, the
+real Prettier extension, the locally pinned Prettier package and a built SharpTS
+language server. It creates its workspace, user data, downloaded extension and
+results under `artifacts/formatter-interop/vscode`; normal editor settings and
+extensions are untouched. This directory is a development/test extension only.
+
+Acquire the optional formatter package with `npm ci --prefix tools/formatter-interop`.
+Install the pinned editor extension into the isolated directories:
+
+```powershell
+code --extensions-dir artifacts/formatter-interop/vscode/extensions --user-data-dir artifacts/formatter-interop/vscode/user-data --install-extension esbenp.prettier-vscode@12.4.0 --force
+```
+
+Build the Release language server, then run:
+
+```powershell
+dotnet build src/SharpTS.LanguageServer/SharpTS.LanguageServer.csproj -c Release
+node scripts/editor-smoke/vscode/run.mjs
+```
+
+`--code`, `--server`, `--dotnet` and `--artifacts` accept explicit paths. The
+default editor path is the standard Windows per-user VS Code installation;
+other platforms use `code` from PATH. The runner stops after 45 seconds.
+
+The test opens and dirties a TypeScript document, then calls the actual editor
+save pipeline twice. The installed Prettier extension must produce exactly the
+output of local Prettier 3.9.9, and its current-run extension log must confirm that
+both saves used that exact local module and version. A small test-only hover bridge keeps a real
+`sharpts-lsp --language-features interop-only` process active and obtains SharpTS
+.NET decorator hover through VS Code before and after both saves. The initialize
+response must advertise hover and no formatting capabilities. This verifies
+editor-owned formatting coexistence; it does not exercise the shipping SharpTS
+VS Code extension's activation or packaging.
+
+The machine-readable evidence is `artifacts/formatter-interop/vscode/result.json`;
+extension-host output is saved alongside it.
diff --git a/scripts/editor-smoke/vscode/extension.cjs b/scripts/editor-smoke/vscode/extension.cjs
new file mode 100644
index 000000000..c8d798d6f
--- /dev/null
+++ b/scripts/editor-smoke/vscode/extension.cjs
@@ -0,0 +1,2 @@
+// Development/test extension only. The test runner owns its temporary LSP bridge.
+exports.activate = function activate() {};
diff --git a/scripts/editor-smoke/vscode/package.json b/scripts/editor-smoke/vscode/package.json
new file mode 100644
index 000000000..678f8ccd6
--- /dev/null
+++ b/scripts/editor-smoke/vscode/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "formatter-interop-smoke",
+ "publisher": "sharpts-tests",
+ "version": "1.0.0",
+ "private": true,
+ "engines": { "vscode": "^1.101.0" },
+ "activationEvents": ["onStartupFinished"],
+ "main": "./extension.cjs"
+}
diff --git a/scripts/editor-smoke/vscode/run.mjs b/scripts/editor-smoke/vscode/run.mjs
new file mode 100644
index 000000000..27733bd90
--- /dev/null
+++ b/scripts/editor-smoke/vscode/run.mjs
@@ -0,0 +1,125 @@
+import assert from "node:assert/strict";
+import { spawn, spawnSync } from "node:child_process";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
+const root = path.resolve(scriptDirectory, "../../..");
+const values = new Map();
+for (let index = 2; index < process.argv.length; index += 2) {
+ assert.ok(process.argv[index]?.startsWith("--"), "arguments must be --name value pairs");
+ assert.ok(process.argv[index + 1], `missing value for ${process.argv[index]}`);
+ values.set(process.argv[index].slice(2), process.argv[index + 1]);
+}
+const artifacts = path.resolve(root, values.get("artifacts") ?? "artifacts/formatter-interop/vscode");
+const code = values.get("code") ?? (process.platform === "win32"
+ ? path.join(process.env.LOCALAPPDATA, "Programs/Microsoft VS Code/Code.exe")
+ : "code");
+const config = {
+ root,
+ workspace: path.join(artifacts, "workspace"),
+ fixture: path.join(artifacts, "workspace/format-on-save.ts"),
+ result: path.join(artifacts, "extension-result.json"),
+ server: path.resolve(root, values.get("server") ?? "src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll"),
+ dotnet: values.get("dotnet") ?? "dotnet",
+ prettierPath: path.join(root, "tools/formatter-interop/node_modules/prettier"),
+ prettierVersion: "3.9.9",
+ extensionVersion: "12.4.0",
+};
+await fs.access(config.server);
+await fs.access(path.join(config.prettierPath, "package.json"));
+await fs.mkdir(path.join(config.workspace, ".vscode"), { recursive: true });
+await fs.rm(config.result, { force: true });
+const finalResult = path.join(artifacts, "result.json");
+await fs.rm(finalResult, { force: true });
+await fs.writeFile(config.fixture,
+ '@DotNetType("System.Text.StringBuilder")\ndeclare class Builder{constructor();Append(value:string):Builder;}\nconst message={text:"hello",count:1};\n');
+await fs.writeFile(path.join(config.workspace, ".prettierrc.json"),
+ JSON.stringify({ semi: true, singleQuote: false, endOfLine: "lf" }) + "\n");
+await fs.writeFile(path.join(config.workspace, ".vscode/settings.json"),
+ JSON.stringify({
+ "editor.formatOnSave": true,
+ "editor.formatOnSaveMode": "file",
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.codeActionsOnSave": {},
+ "prettier.prettierPath": config.prettierPath,
+ "prettier.requireConfig": true,
+ "typescript.format.enable": false,
+ "files.autoSave": "off",
+ "security.workspace.trust.enabled": false,
+ "update.mode": "none",
+ "extensions.autoCheckUpdates": false,
+ "extensions.autoUpdate": false,
+ "telemetry.telemetryLevel": "off",
+ }, null, 2) + "\n");
+const configPath = path.join(artifacts, "test-config.json");
+await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n");
+
+const args = [
+ "--user-data-dir", path.join(artifacts, "user-data"),
+ "--extensions-dir", path.join(artifacts, "extensions"),
+ "--extensionDevelopmentPath", scriptDirectory,
+ "--extensionTestsPath", path.join(scriptDirectory, "test.cjs"),
+ "--disable-workspace-trust", "--skip-welcome", "--skip-release-notes",
+ "--disable-updates", "--disable-telemetry", "--disable-gpu", "--new-window",
+ config.workspace,
+];
+const env = { ...process.env, SHARPTS_EDITOR_SMOKE_CONFIG: configPath };
+delete env.ELECTRON_RUN_AS_NODE;
+const startedAt = Date.now();
+const child = spawn(code, args, { cwd: root, env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
+let log = "";
+child.stdout.on("data", (chunk) => { log += chunk; });
+child.stderr.on("data", (chunk) => { log += chunk; });
+let timedOut = false;
+const timer = setTimeout(() => {
+ timedOut = true;
+ if (process.platform === "win32") {
+ spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true });
+ } else child.kill("SIGKILL");
+}, 45000);
+const exitCode = await new Promise((resolve, reject) => {
+ child.once("error", reject);
+ child.once("exit", resolve);
+}).finally(() => clearTimeout(timer));
+await fs.writeFile(path.join(artifacts, "extension-host.log"), log);
+assert.equal(timedOut, false, `VS Code editor smoke exceeded 45 seconds; see ${artifacts}`);
+assert.equal(exitCode, 0, `VS Code editor smoke failed; see ${path.join(artifacts, "extension-host.log")}`);
+const result = JSON.parse(await fs.readFile(config.result, "utf8"));
+assert.equal(result.status, "passed");
+
+// Equal output alone could also come from the extension's bundled formatter.
+// Verify the real pinned extension logged the requested local module/version
+// during this run, rather than silently falling back to its bundled Prettier.
+async function prettierLogs(directory) {
+ const found = [];
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
+ const entryPath = path.join(directory, entry.name);
+ if (entry.isDirectory()) found.push(...await prettierLogs(entryPath));
+ else if (entry.isFile() && entry.name.endsWith("-Prettier.log")) {
+ if ((await fs.stat(entryPath)).mtimeMs >= startedAt) found.push(entryPath);
+ }
+ }
+ return found;
+}
+const logPaths = await prettierLogs(path.join(artifacts, "user-data/logs"));
+const normalizeModulePath = (value) => process.platform === "win32"
+ ? path.resolve(value).toLowerCase()
+ : path.resolve(value);
+let verifiedInstances = 0;
+for (const logPath of logPaths) {
+ const text = await fs.readFile(logPath, "utf8");
+ for (const match of text.matchAll(/PrettierInstance:\r?\n(\{[\s\S]*?\r?\n\})/g)) {
+ const instance = JSON.parse(match[1]);
+ assert.equal(instance.version, config.prettierVersion, "editor formatter version must be pinned");
+ assert.equal(normalizeModulePath(instance.modulePath), normalizeModulePath(config.prettierPath),
+ "editor must use the pinned local formatter module");
+ verifiedInstances++;
+ }
+}
+assert.ok(verifiedInstances >= 2, "both saves must log the actual pinned formatter instance");
+result.formatterModuleVerified = true;
+result.prettierExtensionLogs = logPaths;
+await fs.writeFile(finalResult, JSON.stringify(result, null, 2) + "\n");
+console.log(JSON.stringify(result, null, 2));
diff --git a/scripts/editor-smoke/vscode/test.cjs b/scripts/editor-smoke/vscode/test.cjs
new file mode 100644
index 000000000..93305fde3
--- /dev/null
+++ b/scripts/editor-smoke/vscode/test.cjs
@@ -0,0 +1,188 @@
+const assert = require("node:assert/strict");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+const { pathToFileURL } = require("node:url");
+const vscode = require("vscode");
+
+// Invoked by VS Code's real extension-host test runner, without a test framework.
+exports.run = async function run() {
+ const config = JSON.parse(
+ await fs.readFile(process.env.SHARPTS_EDITOR_SMOKE_CONFIG, "utf8"),
+ );
+ const { LspStdioClient } = await import(
+ pathToFileURL(path.join(config.root, "scripts/lib/lsp-stdio.mjs")).href
+ );
+ const prettier = require(config.prettierPath);
+ assert.equal(prettier.version, config.prettierVersion);
+ const prettierExtension = vscode.extensions.getExtension(
+ "esbenp.prettier-vscode",
+ );
+ assert.ok(prettierExtension, "isolated Prettier extension must be installed");
+ assert.equal(prettierExtension.packageJSON.version, config.extensionVersion);
+ await prettierExtension.activate();
+
+ const client = new LspStdioClient(
+ config.dotnet,
+ [config.server, "--language-features", "interop-only", "--diagnostics", "off"],
+ { cwd: config.workspace, timeoutMs: 10000 },
+ );
+ const registrations = [];
+ let hoverRequests = 0;
+ let lastHover;
+ let opened;
+ try {
+ const initialize = await client.request("initialize", {
+ processId: process.pid,
+ rootUri: vscode.Uri.file(config.workspace).toString(),
+ capabilities: {
+ workspace: { configuration: false },
+ textDocument: {
+ synchronization: { dynamicRegistration: false, didSave: true },
+ hover: {
+ dynamicRegistration: false,
+ contentFormat: ["markdown", "plaintext"],
+ },
+ formatting: { dynamicRegistration: false },
+ rangeFormatting: { dynamicRegistration: false },
+ onTypeFormatting: { dynamicRegistration: false },
+ },
+ },
+ });
+ assert.equal(initialize.error, undefined);
+ const capabilities = initialize.result.capabilities;
+ assert.ok(capabilities.hoverProvider, "SharpTS hover must be advertised");
+ for (const name of [
+ "documentFormattingProvider",
+ "documentRangeFormattingProvider",
+ "documentOnTypeFormattingProvider",
+ ]) {
+ assert.ok(!capabilities[name], `SharpTS must not own ${name}`);
+ }
+ client.notify("initialized", {});
+
+ const uri = vscode.Uri.file(config.fixture);
+ opened = await vscode.workspace.openTextDocument(uri);
+ await vscode.window.showTextDocument(opened);
+ client.notify("textDocument/didOpen", {
+ textDocument: {
+ uri: uri.toString(),
+ languageId: opened.languageId,
+ version: opened.version,
+ text: opened.getText(),
+ },
+ });
+ registrations.push(
+ vscode.workspace.onDidChangeTextDocument((event) => {
+ if (event.document.uri.toString() === uri.toString()) {
+ client.notify("textDocument/didChange", {
+ textDocument: { uri: uri.toString(), version: event.document.version },
+ contentChanges: [{ text: event.document.getText() }],
+ });
+ }
+ }),
+ vscode.languages.registerHoverProvider(
+ { language: "typescript", scheme: "file" },
+ {
+ async provideHover(document, position) {
+ if (document.uri.toString() !== uri.toString()) return undefined;
+ const reply = await client.request("textDocument/hover", {
+ textDocument: { uri: uri.toString() },
+ position: { line: position.line, character: position.character },
+ });
+ assert.equal(reply.error, undefined);
+ lastHover = reply.result;
+ hoverRequests++;
+ if (!lastHover) return undefined;
+ const contents = lastHover.contents;
+ const text = typeof contents === "string" ? contents : contents.value;
+ return new vscode.Hover(new vscode.MarkdownString(text));
+ },
+ },
+ ),
+ );
+
+ async function assertSharpTsHover() {
+ const countBefore = hoverRequests;
+ // VS Code may log and swallow a provider exception. A previous successful
+ // hover must never count as proof that this fresh request succeeded.
+ lastHover = undefined;
+ await vscode.commands.executeCommand(
+ "vscode.executeHoverProvider",
+ uri,
+ new vscode.Position(0, 4),
+ );
+ assert.ok(hoverRequests > countBefore, "editor must obtain a fresh SharpTS response");
+ assert.match(JSON.stringify(lastHover), /System\.Text\.StringBuilder/);
+ }
+
+ await assertSharpTsHover();
+ const before = opened.getText();
+ const expected = await prettier.format(before, {
+ ...(await prettier.resolveConfig(config.fixture)),
+ filepath: config.fixture,
+ });
+ assert.notEqual(before, expected, "fixture must require formatting");
+ assert.equal(
+ vscode.workspace.getConfiguration("editor", uri).get("defaultFormatter"),
+ "esbenp.prettier-vscode",
+ );
+ assert.equal(
+ vscode.workspace.getConfiguration("editor", uri).get("formatOnSave"),
+ true,
+ );
+
+ // A clean document's save can bypass save participants. Make a real edit,
+ // then let VS Code's save pipeline invoke the installed formatter itself.
+ const dirtyEdit = new vscode.WorkspaceEdit();
+ dirtyEdit.insert(uri, opened.positionAt(before.length), " ");
+ assert.equal(await vscode.workspace.applyEdit(dirtyEdit), true);
+ assert.equal(opened.isDirty, true);
+ assert.equal(await opened.save(), true);
+ assert.equal(opened.isDirty, false);
+ assert.equal(opened.getText(), expected, "format-on-save must match pinned Prettier");
+ assert.equal(await fs.readFile(config.fixture, "utf8"), expected);
+ await assertSharpTsHover();
+ const firstSavedVersion = opened.version;
+
+ // A second dirty save proves the real save participant remains idempotent.
+ const secondEdit = new vscode.WorkspaceEdit();
+ secondEdit.insert(uri, opened.positionAt(expected.length), " ");
+ assert.equal(await vscode.workspace.applyEdit(secondEdit), true);
+ assert.equal(await opened.save(), true);
+ assert.equal(opened.getText(), expected);
+ assert.ok(opened.version > firstSavedVersion);
+ await assertSharpTsHover();
+
+ await fs.writeFile(
+ config.result,
+ JSON.stringify(
+ {
+ status: "passed",
+ editor: `VS Code ${vscode.version}`,
+ platform: `${process.platform}-${process.arch}`,
+ formatterExtension: `esbenp.prettier-vscode ${prettierExtension.packageJSON.version}`,
+ formatter: `Prettier ${prettier.version}`,
+ serverMode: "interop-only",
+ formatterOwner: "esbenp.prettier-vscode",
+ sharpTsHoverRequests: hoverRequests,
+ saves: 2,
+ exactFormattedOutput: true,
+ noServerFormattingCapability: true,
+ },
+ null,
+ 2,
+ ) + "\n",
+ );
+ } finally {
+ for (const registration of registrations) registration.dispose();
+ try {
+ if (opened) {
+ client.notify("textDocument/didClose", {
+ textDocument: { uri: opened.uri.toString() },
+ });
+ }
+ } finally {
+ await client.close();
+ }
+ }
+};
diff --git a/scripts/lib/lsp-stdio.mjs b/scripts/lib/lsp-stdio.mjs
new file mode 100644
index 000000000..c5b74cff5
--- /dev/null
+++ b/scripts/lib/lsp-stdio.mjs
@@ -0,0 +1,147 @@
+import { spawn } from "node:child_process";
+
+/** Small bounded stdio client for repository smoke tests, with no runtime dependencies. */
+export class LspStdioClient {
+ registeredMethods = [];
+ notifications = [];
+ #process;
+ #pending = new Map();
+ #nextId = 1;
+ #buffer = Buffer.alloc(0);
+ #stderr = "";
+ #closing = false;
+ #exit;
+ #failure;
+
+ constructor(command, args, { cwd, timeoutMs = 20_000 } = {}) {
+ this.timeoutMs = timeoutMs;
+ this.#process = spawn(command, args, { cwd, windowsHide: true, stdio: "pipe" });
+ this.#exit = new Promise((resolve) => {
+ this.#process.once("close", (code, signal) => {
+ this.#fail(new Error(`LSP exited (${code ?? signal}): ${this.#stderr}`));
+ resolve({ code, signal });
+ });
+ });
+ this.#process.on("error", (error) => this.#fail(error));
+ this.#process.stdin.on("error", (error) => this.#fail(error));
+ this.#process.stderr.on("data", (chunk) => {
+ this.#stderr = (this.#stderr + chunk.toString("utf8")).slice(-65_536);
+ });
+ this.#process.stdout.on("data", (chunk) => {
+ try {
+ this.#buffer = Buffer.concat([this.#buffer, chunk]);
+ if (this.#buffer.length > 16 * 1024 * 1024) throw new Error("LSP frame exceeds smoke limit");
+ this.#readFrames();
+ } catch (error) {
+ this.#fail(error);
+ this.#process.kill();
+ }
+ });
+ }
+
+ #fail(error) {
+ this.#failure ??= error;
+ for (const { reject, timer } of this.#pending.values()) {
+ clearTimeout(timer);
+ reject(error);
+ }
+ this.#pending.clear();
+ }
+
+ #send(message) {
+ if (this.#failure) throw this.#failure;
+ const data = Buffer.from(JSON.stringify({ jsonrpc: "2.0", ...message }), "utf8");
+ this.#process.stdin.write(Buffer.concat([
+ Buffer.from(`Content-Length: ${data.length}\r\n\r\n`, "ascii"), data,
+ ]));
+ }
+
+ #readFrames() {
+ for (;;) {
+ const headerEnd = this.#buffer.indexOf("\r\n\r\n");
+ if (headerEnd < 0) return;
+ const header = this.#buffer.subarray(0, headerEnd).toString("ascii");
+ const length = /^content-length:\s*(\d+)\s*$/im.exec(header);
+ if (!length) throw new Error(`Invalid LSP header: ${header}`);
+ const size = Number(length[1]);
+ if (size > 16 * 1024 * 1024) throw new Error("LSP payload exceeds smoke limit");
+ const end = headerEnd + 4 + size;
+ if (this.#buffer.length < end) return;
+ const message = JSON.parse(this.#buffer.subarray(headerEnd + 4, end).toString("utf8"));
+ this.#buffer = this.#buffer.subarray(end);
+ if (message.method && message.id !== undefined) {
+ // Clients in these tests have no configuration or registration side effects.
+ if (message.method === "workspace/configuration") {
+ this.#send({ id: message.id, result: (message.params?.items ?? []).map(() => null) });
+ } else if (message.method === "client/registerCapability") {
+ this.registeredMethods.push(...(message.params?.registrations ?? []).map((item) => item.method));
+ this.#send({ id: message.id, result: null });
+ } else {
+ this.#send({ id: message.id, error: { code: -32601, message: "Unsupported smoke client method" } });
+ }
+ } else if (message.id !== undefined) {
+ const pending = this.#pending.get(message.id);
+ if (!pending) continue;
+ this.#pending.delete(message.id);
+ clearTimeout(pending.timer);
+ pending.resolve(message);
+ } else if (message.method) {
+ this.notifications.push(message);
+ if (this.notifications.length > 1024) this.notifications.shift();
+ }
+ }
+ }
+
+ notify(method, params) { this.#send({ method, params }); }
+
+ request(method, params) { return this.beginRequest(method, params).response; }
+
+ /** Cancellation sends the LSP notification; it never invents a local server response. */
+ beginRequest(method, params) {
+ const id = this.#nextId++;
+ const response = this.#closing ? Promise.reject(new Error("LSP client is closing"))
+ : this.#failure ? Promise.reject(this.#failure) : new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ this.#pending.delete(id);
+ reject(new Error(`LSP ${method} timed out: ${this.#stderr}`));
+ }, this.timeoutMs);
+ this.#pending.set(id, { resolve, reject, timer });
+ try {
+ this.#send({ id, method, params });
+ } catch (error) {
+ clearTimeout(timer);
+ this.#pending.delete(id);
+ reject(error);
+ }
+ });
+ return {
+ id, response,
+ cancel: () => {
+ if (!this.#pending.has(id)) return false;
+ this.notify("$/cancelRequest", { id });
+ return true;
+ },
+ };
+ }
+
+ async close() {
+ try {
+ if (this.#process.exitCode === null && !this.#process.killed) {
+ const response = await this.request("shutdown", null);
+ if (response.error) throw new Error(JSON.stringify(response.error));
+ this.#closing = true;
+ this.notify("exit", null);
+ let timer;
+ const exit = await Promise.race([
+ this.#exit,
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("LSP exit timed out")), 5_000); }),
+ ]).finally(() => clearTimeout(timer));
+ if (exit.code !== 0) throw new Error(`LSP shutdown failed: ${this.#stderr}`);
+ }
+ } finally {
+ this.#closing = true;
+ if (this.#process.exitCode === null) this.#process.kill();
+ this.#fail(new Error("LSP client closed"));
+ }
+ }
+}
diff --git a/scripts/test-analysis-snapshots.mjs b/scripts/test-analysis-snapshots.mjs
new file mode 100644
index 000000000..ea1830531
--- /dev/null
+++ b/scripts/test-analysis-snapshots.mjs
@@ -0,0 +1,194 @@
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, writeFile, stat, utimes } from "node:fs/promises";
+import { dirname, resolve, join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { setTimeout as delay } from "node:timers/promises";
+import { LspStdioClient } from "./lib/lsp-stdio.mjs";
+
+const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const server = resolve(repository, process.argv[2] ?? "src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll");
+await mkdir(join(repository, "artifacts/analysis-smoke"), { recursive: true });
+const workspace = await mkdtemp(join(repository, "artifacts/analysis-smoke/run-"));
+const source = "import { value } from './dep';\nconst result: number = value;\n";
+const original = "export const value: number = 123;\n";
+const changed = "export const value: string = 'x';\n";
+assert.equal(original.length, changed.length);
+await writeFile(join(workspace, "tsconfig.json"), JSON.stringify({
+ compilerOptions: { noLib: true, types: [] }, include: ["*.ts"],
+}));
+await writeFile(join(workspace, "main.ts"), source);
+await writeFile(join(workspace, "dep.ts"), original);
+const memberDependency = "export class Base {\r\n /* 😀 */ field: number = 1;\r\n read(): number { return this.field; }\r\n}\r\nexport class Derived extends Base {}\r\n";
+const memberSource = "import { Derived as Renamed } from './member-dep';\nconst item = new Renamed();\nitem./*field*/field;\nitem./*method*/read();\nconst loose: any = item; loose./*any*/field;\nconst shape = { field: 1 }; shape./*structural*/field;\n";
+await writeFile(join(workspace, "member-dep.ts"), memberDependency);
+await writeFile(join(workspace, "member-main.ts"), memberSource);
+const uri = (name) => pathToFileURL(join(workspace, name)).href;
+const sameUri = (left, right) => process.platform === "win32"
+ ? left.toLowerCase() === right.toLowerCase() : left === right;
+const asList = (value) => Array.isArray(value) ? value : value ? [value] : [];
+const positionAt = (text, offset) => {
+ const prefix = text.slice(0, offset);
+ const lastLine = prefix.lastIndexOf("\n");
+ return { line: prefix.split("\n").length - 1, character: offset - lastLine - 1 };
+};
+const memberPosition = (marker) => {
+ const comment = `/*${marker}*/`;
+ const offset = memberSource.indexOf(comment);
+ assert.notEqual(offset, -1, `missing marker ${marker}`);
+ return { textDocument: { uri: uri("member-main.ts") }, position: positionAt(memberSource, offset + comment.length + 1) };
+};
+const expectedMember = (text, name) => {
+ const offset = text.indexOf(name === "field" ? "field:" : "read():");
+ assert.notEqual(offset, -1);
+ return { uri: uri("member-dep.ts"), range: { start: positionAt(text, offset), end: positionAt(text, offset + name.length) } };
+};
+const assertMember = (result, expected) => {
+ const locations = asList(result);
+ assert.equal(locations.length, 1);
+ assert.ok(sameUri(locations[0].uri, expected.uri));
+ assert.deepEqual(locations[0].range, expected.range);
+};
+const requestPosition = { textDocument: { uri: uri("main.ts") }, position: { line: 1, character: 23 } };
+const report = { workspace, server, full: {}, interopOnly: {} };
+
+async function request(client, method, params) {
+ const response = await client.request(method, params);
+ assert.equal(response.error, undefined, `${method}: ${JSON.stringify(response.error)}`);
+ return response.result;
+}
+async function initialize(client) {
+ const result = await request(client, "initialize", {
+ processId: process.pid, rootUri: pathToFileURL(workspace).href,
+ workspaceFolders: [{ uri: pathToFileURL(workspace).href, name: "snapshot-smoke" }],
+ capabilities: {
+ workspace: { didChangeWatchedFiles: { dynamicRegistration: true }, workspaceFolders: true },
+ textDocument: { definition: {}, references: {}, rename: { prepareSupport: true },
+ hover: { contentFormat: ["markdown"] }, documentSymbol: {}, publishDiagnostics: { versionSupport: true } },
+ },
+ });
+ client.notify("initialized", {});
+ for (const key of ["documentFormattingProvider", "documentRangeFormattingProvider", "documentOnTypeFormattingProvider"])
+ assert.ok(!result.capabilities[key], key);
+ return result.capabilities;
+}
+async function diagnosticsAfter(client, start, predicate, { document = "main.ts", version = 1 } = {}) {
+ const end = performance.now() + 20_000;
+ while (performance.now() < end) {
+ const match = client.notifications.slice(start).find((item) => item.method === "textDocument/publishDiagnostics" &&
+ sameUri(item.params.uri, uri(document)) && item.params.version === version && predicate(item.params.diagnostics));
+ if (match) return match.params.diagnostics;
+ await delay(25);
+ }
+ throw new Error(`Expected diagnostics were not published: ${JSON.stringify(client.notifications.slice(start))}`);
+}
+
+const full = new LspStdioClient("dotnet", [server, "--language-features", "full", "--diagnostics", "all"], { cwd: workspace });
+try {
+ const caps = await initialize(full);
+ assert.ok(caps.definitionProvider && caps.referencesProvider && caps.renameProvider);
+ full.notify("textDocument/didOpen", { textDocument: { uri: uri("main.ts"), languageId: "typescript", version: 1, text: source } });
+ await diagnosticsAfter(full, 0, (items) => items.length === 0);
+ const definitions = (await Promise.all(Array.from({ length: 4 }, () => request(full, "textDocument/definition", requestPosition)))).map(asList);
+ assert.equal(definitions[0].length, 1);
+ assert.ok(sameUri(definitions[0][0].uri, uri("dep.ts")));
+ definitions.forEach((result) => assert.deepEqual(result, definitions[0]));
+ const references = await request(full, "textDocument/references", { ...requestPosition, context: { includeDeclaration: true } });
+ assert.equal(references.length, 3);
+ assert.ok(await request(full, "textDocument/prepareRename", requestPosition));
+ const rename = await request(full, "textDocument/rename", { ...requestPosition, newName: "nextValue" });
+ assert.equal(Object.values(rename.changes).flat().length, 3);
+
+ const stamp = await stat(join(workspace, "dep.ts"));
+ await writeFile(join(workspace, "dep.ts"), changed);
+ await utimes(join(workspace, "dep.ts"), stamp.atime, stamp.mtime);
+ const changedStart = full.notifications.length;
+ full.notify("workspace/didChangeWatchedFiles", { changes: [{ uri: uri("dep.ts"), type: 2 }] });
+ const errors = await diagnosticsAfter(full, changedStart, (items) => items.some((item) => /not assignable/i.test(item.message)));
+ const dirtyStart = full.notifications.length;
+ full.notify("textDocument/didOpen", { textDocument: { uri: uri("dep.ts"), languageId: "typescript", version: 1, text: original } });
+ await diagnosticsAfter(full, dirtyStart, (items) => items.length === 0);
+ const closeStart = full.notifications.length;
+ full.notify("textDocument/didClose", { textDocument: { uri: uri("dep.ts") } });
+ await diagnosticsAfter(full, closeStart, (items) => items.some((item) => /not assignable/i.test(item.message)));
+
+ await writeFile(join(workspace, "consumer.ts"), "import { value } from './dep'; value;\n");
+ full.notify("workspace/didChangeWatchedFiles", { changes: [{ uri: uri("consumer.ts"), type: 1 }] });
+ const expanded = await request(full, "textDocument/references", { ...requestPosition, context: { includeDeclaration: true } });
+ assert.equal(expanded.length, 5);
+ assert.ok(full.registeredMethods.includes("workspace/didChangeWatchedFiles"));
+ assert.ok(!full.registeredMethods.some((method) => /formatting/i.test(method)));
+
+ const memberStart = full.notifications.length;
+ full.notify("textDocument/didOpen", { textDocument: { uri: uri("member-main.ts"), languageId: "typescript", version: 1, text: memberSource } });
+ await diagnosticsAfter(full, memberStart, (items) => items.length === 0, { document: "member-main.ts" });
+ const memberDefinitions = await Promise.all(Array.from({ length: 4 }, () => request(full, "textDocument/definition", memberPosition("field"))));
+ memberDefinitions.forEach((result) => assertMember(result, expectedMember(memberDependency, "field")));
+ assertMember(await request(full, "textDocument/definition", memberPosition("method")), expectedMember(memberDependency, "read"));
+ assert.equal(asList(await request(full, "textDocument/definition", memberPosition("any"))).length, 0);
+ assert.equal(asList(await request(full, "textDocument/definition", memberPosition("structural"))).length, 0);
+
+ const dirtyMember = "// dirty open dependency\r\n" + memberDependency;
+ const openMemberStart = full.notifications.length;
+ full.notify("textDocument/didOpen", { textDocument: { uri: uri("member-dep.ts"), languageId: "typescript", version: 1, text: dirtyMember } });
+ await diagnosticsAfter(full, openMemberStart, (items) => items.length === 0, { document: "member-dep.ts" });
+ assertMember(await request(full, "textDocument/definition", memberPosition("field")), expectedMember(dirtyMember, "field"));
+ const dirtyMemberV2 = "// version two\r\n" + dirtyMember;
+ const changeMemberStart = full.notifications.length;
+ full.notify("textDocument/didChange", { textDocument: { uri: uri("member-dep.ts"), version: 2 }, contentChanges: [{ text: dirtyMemberV2 }] });
+ await diagnosticsAfter(full, changeMemberStart, (items) => items.length === 0, { document: "member-dep.ts", version: 2 });
+ assertMember(await request(full, "textDocument/definition", memberPosition("field")), expectedMember(dirtyMemberV2, "field"));
+
+ // Force a cold shared analysis, then exercise actual wire cancellation. A server can legally
+ // complete before it observes cancellation; the gated handler tests prove the inflight case.
+ full.notify("workspace/didChangeWatchedFiles", { changes: [{ uri: uri("member-dep.ts"), type: 2 }] });
+ const cancelledDefinition = full.beginRequest("textDocument/definition", memberPosition("field"));
+ const cancelSent = cancelledDefinition.cancel();
+ const cancelledResponse = await cancelledDefinition.response;
+ let cancellationOutcome;
+ if (cancelledResponse.error) {
+ assert.equal(cancelledResponse.error.code, -32800);
+ cancellationOutcome = "request-cancelled";
+ } else if (asList(cancelledResponse.result).length === 0) {
+ cancellationOutcome = "no-result-during-invalidation";
+ } else {
+ assertMember(cancelledResponse.result, expectedMember(dirtyMemberV2, "field"));
+ cancellationOutcome = "completed-before-cancel";
+ }
+ assertMember(await request(full, "textDocument/definition", memberPosition("field")), expectedMember(dirtyMemberV2, "field"));
+
+ const removedMember = dirtyMemberV2.replaceAll("field", "renamed");
+ const removeMemberStart = full.notifications.length;
+ full.notify("textDocument/didChange", { textDocument: { uri: uri("member-dep.ts"), version: 3 }, contentChanges: [{ text: removedMember }] });
+ await diagnosticsAfter(full, removeMemberStart, (items) => items.length === 0, { document: "member-dep.ts", version: 3 });
+ assert.equal(asList(await request(full, "textDocument/definition", memberPosition("field"))).length, 0);
+ assertMember(await request(full, "textDocument/definition", memberPosition("method")), expectedMember(removedMember, "read"));
+ const closeMemberStart = full.notifications.length;
+ full.notify("textDocument/didClose", { textDocument: { uri: uri("member-dep.ts") } });
+ await diagnosticsAfter(full, closeMemberStart, (items) => items.length === 0, { document: "member-main.ts" });
+ assertMember(await request(full, "textDocument/definition", memberPosition("field")), expectedMember(memberDependency, "field"));
+
+ report.full = { concurrentDefinitions: 4, references: references.length, expandedReferences: expanded.length,
+ renameEdits: 3, closedDiskErrors: errors.length, dirtyOverlayAndClose: "passed", watching: "registered",
+ members: { concurrentDefinitions: 4, inheritedFieldAndMethod: "passed", utf16CrLfRanges: "passed", refusedAnyAndStructural: "passed",
+ dirtyVersions: [1, 2, 3], removedMemberRefused: true, closeRestoredDisk: true, cancelSent, cancellationOutcome, freshAfterCancellation: true } };
+} finally { await full.close(); }
+
+const interop = new LspStdioClient("dotnet", [server, "--language-features", "interop-only"], { cwd: workspace });
+try {
+ const caps = await initialize(interop);
+ for (const key of ["definitionProvider", "referencesProvider", "renameProvider", "documentSymbolProvider"])
+ assert.ok(!caps[key], key);
+ const text = '@DotNetType("System.String")\ndeclare class NetString {}\n';
+ interop.notify("textDocument/didOpen", { textDocument: { uri: uri("interop.ts"), languageId: "typescript", version: 1, text } });
+ const hover = await request(interop, "textDocument/hover", { textDocument: { uri: uri("interop.ts") }, position: { line: 0, character: 4 } });
+ assert.match(JSON.stringify(hover), /System\.String/);
+ const refused = await interop.request("textDocument/definition", { textDocument: { uri: uri("interop.ts") }, position: { line: 1, character: 15 } });
+ assert.equal(refused.error?.code, -32601);
+ interop.notify("textDocument/didOpen", { textDocument: { uri: uri("member-main.ts"), languageId: "typescript", version: 1, text: memberSource } });
+ const refusedMember = await interop.request("textDocument/definition", memberPosition("field"));
+ assert.equal(refusedMember.error?.code, -32601);
+ assert.ok(!interop.registeredMethods.some((method) => /formatting/i.test(method)));
+ report.interopOnly = { generalCapabilitiesAbsent: true, clrHover: "passed", definitionError: -32601, memberDefinitionError: -32601 };
+} finally { await interop.close(); }
+await writeFile(join(workspace, "result.json"), JSON.stringify(report, null, 2) + "\n");
+console.log(JSON.stringify(report, null, 2));
diff --git a/scripts/test-editor-contract.ps1 b/scripts/test-editor-contract.ps1
new file mode 100644
index 000000000..bcd7ef108
--- /dev/null
+++ b/scripts/test-editor-contract.ps1
@@ -0,0 +1,288 @@
+[CmdletBinding()]
+param(
+ [ValidateSet('Debug', 'Release')]
+ [string]$Configuration = 'Release',
+ [string]$ResultsDirectory = 'artifacts/editor-contract',
+ [string]$NodeExecutable = 'node',
+ [switch]$NoBuild,
+ [switch]$NoRestore,
+ [switch]$NoFormatterInstall
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$repositoryRoot = Split-Path -Parent $PSScriptRoot
+$resultsRoot = [IO.Path]::GetFullPath($ResultsDirectory, $repositoryRoot)
+$runDirectory = Join-Path $resultsRoot (Get-Date -Format 'yyyyMMddTHHmmss')
+$runDirectory += '-' + [Guid]::NewGuid().ToString('N').Substring(0, 8)
+New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null
+$indexPath = Join-Path $runDirectory 'index.json'
+$nodeCommand = (Get-Command $NodeExecutable -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
+$pinnedNode = (Get-Content -LiteralPath (Join-Path $repositoryRoot '.node-version') -Raw).Trim()
+$sdkPolicy = Get-Content -LiteralPath (Join-Path $repositoryRoot 'global.json') -Raw | ConvertFrom-Json
+$commands = [Collections.Generic.List[object]]::new()
+$suites = [Collections.Generic.List[object]]::new()
+$failures = [Collections.Generic.List[string]]::new()
+$cleanup = [Collections.Generic.List[object]]::new()
+$script:evidence = [ordered]@{
+ schemaVersion = 1
+ completed = $false
+ startedUtc = [DateTime]::UtcNow.ToString('o')
+ repository = $repositoryRoot
+ results = $runDirectory
+ configuration = $Configuration
+ platform = [Runtime.InteropServices.RuntimeInformation]::OSDescription
+ architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
+ node = [ordered]@{ executable = $nodeCommand; pinned = $pinnedNode; actual = $null }
+ sdkPolicy = $sdkPolicy
+ sourceCommit = $null
+ sourceDirty = $null
+ commands = $commands
+ suites = $suites
+ cleanup = $cleanup
+ failures = $failures
+}
+
+function Save-Evidence {
+ $script:evidence | ConvertTo-Json -Depth 40 | Set-Content -LiteralPath $indexPath -Encoding utf8NoBOM
+}
+
+function Invoke-BoundedProcess {
+ param(
+ [Parameter(Mandatory)][string]$Name,
+ [Parameter(Mandatory)][string]$Executable,
+ [string[]]$Arguments = @(),
+ [string]$WorkingDirectory = $repositoryRoot,
+ [ValidateRange(1, 1200)][int]$TimeoutSeconds = 180,
+ [hashtable]$Environment = @{}
+ )
+ $stdoutPath = Join-Path $runDirectory "$Name.stdout.log"
+ $stderrPath = Join-Path $runDirectory "$Name.stderr.log"
+ $record = [ordered]@{
+ name = $Name; executable = $Executable; arguments = $Arguments; cwd = $WorkingDirectory
+ timeoutSeconds = $TimeoutSeconds; startedUtc = [DateTime]::UtcNow.ToString('o')
+ stdout = $stdoutPath; stderr = $stderrPath; exitCode = $null
+ timedOut = $false; passed = $false; elapsedSeconds = 0; error = $null
+ }
+ $commands.Add($record)
+ Save-Evidence
+ Write-Host "> $Executable $($Arguments -join ' ')"
+ $start = [Diagnostics.ProcessStartInfo]::new()
+ $start.FileName = $Executable
+ $start.WorkingDirectory = $WorkingDirectory
+ $start.UseShellExecute = $false
+ $start.CreateNoWindow = $true
+ $start.RedirectStandardOutput = $true
+ $start.RedirectStandardError = $true
+ foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) }
+ foreach ($key in $Environment.Keys) { $start.Environment[$key] = [string]$Environment[$key] }
+ $process = [Diagnostics.Process]::new()
+ $process.StartInfo = $start
+ $watch = [Diagnostics.Stopwatch]::StartNew()
+ $started = $false
+ $stdoutTask = $null
+ $stderrTask = $null
+ try {
+ $started = $process.Start()
+ if (-not $started) { throw 'Process did not start.' }
+ $stdoutTask = $process.StandardOutput.ReadToEndAsync()
+ $stderrTask = $process.StandardError.ReadToEndAsync()
+ if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
+ $record.timedOut = $true
+ $process.Kill($true)
+ if (-not $process.WaitForExit(10000)) { throw 'Timed-out process tree did not terminate.' }
+ }
+ $record.exitCode = $process.ExitCode
+ $record.passed = -not $record.timedOut -and $process.ExitCode -eq 0
+ }
+ catch { $record.error = $_.Exception.Message }
+ finally {
+ if ($started -and -not $process.HasExited) {
+ try {
+ $process.Kill($true)
+ if (-not $process.WaitForExit(10000)) { throw 'Failed process tree did not settle during cleanup.' }
+ }
+ catch { $record.passed = $false; $record.error = $_.Exception.Message }
+ }
+ if ($null -ne $stdoutTask -and $stdoutTask.Wait(5000)) {
+ $stdoutTask.GetAwaiter().GetResult() | Set-Content -LiteralPath $stdoutPath -Encoding utf8NoBOM
+ }
+ else { 'Output pipe did not settle before the cleanup deadline.' | Set-Content -LiteralPath $stdoutPath }
+ if ($null -ne $stderrTask -and $stderrTask.Wait(5000)) {
+ $stderrTask.GetAwaiter().GetResult() | Set-Content -LiteralPath $stderrPath -Encoding utf8NoBOM
+ }
+ else { 'Error pipe did not settle before the cleanup deadline.' | Set-Content -LiteralPath $stderrPath }
+ $record.elapsedSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3)
+ $process.Dispose()
+ Save-Evidence
+ }
+ if (-not $record.passed) {
+ Write-Host "FAILED $Name (exit=$($record.exitCode), timeout=$($record.timedOut)); $stderrPath"
+ Get-Content -LiteralPath $stderrPath -Tail 20 | Write-Host
+ }
+ return $record
+}
+
+function Invoke-RequiredProcess {
+ param([string]$Name, [string]$Executable, [string[]]$Arguments, [int]$TimeoutSeconds = 180,
+ [string]$WorkingDirectory = $repositoryRoot, [hashtable]$Environment = @{})
+ $record = Invoke-BoundedProcess -Name $Name -Executable $Executable -Arguments $Arguments `
+ -TimeoutSeconds $TimeoutSeconds -WorkingDirectory $WorkingDirectory -Environment $Environment
+ if (-not $record.passed) { throw "$Name failed; see $($record.stderr)." }
+ return $record
+}
+
+function Remove-IsolatedDirectory([string]$Path) {
+ $absolute = [IO.Path]::GetFullPath($Path)
+ $expectedParent = [IO.Path]::GetFullPath((Join-Path $runDirectory 'packaged')) + [IO.Path]::DirectorySeparatorChar
+ $comparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal }
+ if (-not $absolute.StartsWith($expectedParent, $comparison)) {
+ throw "Refusing cleanup outside this run's isolated packaged directory: $absolute"
+ }
+ if (Test-Path -LiteralPath $absolute) { Remove-Item -LiteralPath $absolute -Recurse -Force }
+ $cleanup.Add([ordered]@{ path = $absolute; removed = $true })
+}
+
+$originalPath = $env:PATH
+$packagedRoot = Join-Path $runDirectory 'packaged'
+try {
+ # The formatter wrapper invokes node by name. Keep that child invocation pinned too.
+ $env:PATH = (Split-Path -Parent $nodeCommand) + [IO.Path]::PathSeparator + $originalPath
+ $version = Invoke-RequiredProcess 'node-version' $nodeCommand @('--version')
+ $actualNode = (Get-Content -LiteralPath $version.stdout -Raw).Trim().TrimStart('v')
+ $script:evidence.node.actual = $actualNode
+ if ($actualNode -cne $pinnedNode) { throw "Node $pinnedNode is required; pass -NodeExecutable for an isolated pinned runtime (found $actualNode)." }
+ $commit = Invoke-RequiredProcess 'source-commit' 'git' @('rev-parse', 'HEAD')
+ $script:evidence.sourceCommit = (Get-Content -LiteralPath $commit.stdout -Raw).Trim()
+ $dirty = Invoke-RequiredProcess 'source-status' 'git' @('status', '--porcelain')
+ $script:evidence.sourceDirty = -not [string]::IsNullOrWhiteSpace((Get-Content -LiteralPath $dirty.stdout -Raw))
+ $null = Invoke-RequiredProcess 'dotnet-info' 'dotnet' @('--info')
+ $null = Invoke-RequiredProcess 'dotnet-runtimes' 'dotnet' @('--list-runtimes')
+
+ if (-not $NoFormatterInstall) {
+ $nodeDirectory = Split-Path -Parent $nodeCommand
+ $npmCandidates = @((Join-Path $nodeDirectory 'node_modules/npm/bin/npm-cli.js'),
+ (Join-Path $nodeDirectory '../lib/node_modules/npm/bin/npm-cli.js'))
+ $npmCli = $npmCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
+ if (-not $npmCli) { throw 'Cannot find npm-cli.js beside the pinned Node runtime; install with npm ci and use -NoFormatterInstall.' }
+ $null = Invoke-RequiredProcess 'acquire-formatter' $nodeCommand @($npmCli, 'ci', '--prefix',
+ (Join-Path $repositoryRoot 'tools/formatter-interop'), '--ignore-scripts', '--no-audit', '--no-fund') -TimeoutSeconds 300
+ }
+
+ $projects = [ordered]@{
+ 'language-server' = 'src/SharpTS.LanguageServer/SharpTS.LanguageServer.csproj'
+ 'formatter-evidence' = 'tests/fixtures/FormatterInterop/SharpTS.FormatterInteropEvidence.csproj'
+ }
+ if (-not $NoBuild) {
+ foreach ($name in $projects.Keys) {
+ if (-not $NoRestore) {
+ $null = Invoke-RequiredProcess "restore-$name" 'dotnet' @('restore', $projects[$name]) -TimeoutSeconds 300
+ }
+ $null = Invoke-RequiredProcess "build-$name" 'dotnet' @('build', $projects[$name], '--no-restore',
+ '--configuration', $Configuration, '--nologo', '--verbosity', 'minimal') -TimeoutSeconds 480
+ }
+ }
+ $server = Join-Path $repositoryRoot "src/SharpTS.LanguageServer/bin/$Configuration/net10.0/SharpTS.LanguageServer.dll"
+ $formatterEvidence = Join-Path $repositoryRoot "tests/fixtures/FormatterInterop/bin/$Configuration/net10.0/SharpTS.FormatterInteropEvidence.dll"
+ foreach ($binary in @($server, $formatterEvidence)) {
+ if (-not (Test-Path -LiteralPath $binary -PathType Leaf)) { throw "Required build output is missing: $binary" }
+ }
+ $serverCompiler = Join-Path (Split-Path -Parent $server) 'SharpTS.dll'
+ $evidenceCompiler = Join-Path (Split-Path -Parent $formatterEvidence) 'SharpTS.dll'
+ $script:evidence['binaries'] = @($server, $serverCompiler, $formatterEvidence, $evidenceCompiler) | ForEach-Object {
+ [ordered]@{ path = $_; sha256 = (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash.ToLowerInvariant() }
+ }
+ if ((Get-FileHash -LiteralPath $serverCompiler -Algorithm SHA256).Hash -cne
+ (Get-FileHash -LiteralPath $evidenceCompiler -Algorithm SHA256).Hash) {
+ throw 'Formatter evidence has a stale compiler copy. Build its project with -p:BuildProjectReferences=false before using -NoBuild.'
+ }
+
+ foreach ($suite in @('analysis-snapshots', 'semantic-hover', 'semantic-completion',
+ 'semantic-signatures', 'member-references', 'private-rename')) {
+ $record = Invoke-BoundedProcess "protocol-$suite" $nodeCommand @((Join-Path $PSScriptRoot "test-$suite.mjs"), $server) -TimeoutSeconds 180
+ $suiteResult = [ordered]@{ name = $suite; passed = $record.passed; report = $null; log = $record.stdout }
+ if ($record.passed) {
+ try {
+ $suiteResult.report = Get-Content -LiteralPath $record.stdout -Raw | ConvertFrom-Json
+ $report = $suiteResult.report
+ if (-not $report.workspace -or -not (Test-Path -LiteralPath $report.workspace -PathType Container)) {
+ throw 'Result must identify the executed disposable workspace.'
+ }
+ if ($suite -in @('analysis-snapshots', 'member-references')) {
+ if (@($report.full.PSObject.Properties).Count -eq 0 -or @($report.interopOnly.PSObject.Properties).Count -eq 0) {
+ throw 'Both feature modes must have nonempty results.'
+ }
+ }
+ elseif (@($report.clients).Count -lt 2 -or
+ @($report.clients | Where-Object { $_.mode -eq 'full' }).Count -eq 0 -or
+ @($report.clients | Where-Object { $_.mode -eq 'interop-only' }).Count -eq 0) {
+ throw 'Both feature modes must have nonempty client results.'
+ }
+ }
+ catch { $suiteResult.passed = $false; $failures.Add("$suite emitted no valid result manifest: $($_.Exception.Message)") }
+ }
+ if (-not $suiteResult.passed) { $failures.Add("Protocol suite failed: $suite") }
+ $suites.Add($suiteResult)
+ Save-Evidence
+ }
+
+ # The optional formatter and its compiler evidence project remain outside product builds.
+ $formatterResults = Join-Path $runDirectory 'formatter'
+ $pwsh = (Get-Process -Id $PID).Path
+ $record = Invoke-BoundedProcess 'formatter-interop' $pwsh @('-NoProfile', '-NonInteractive', '-File',
+ (Join-Path $PSScriptRoot 'test-formatter-interop.ps1'), '-Configuration', $Configuration,
+ '-ResultsDirectory', $formatterResults, '-NoBuild') -TimeoutSeconds 720
+ $suites.Add([ordered]@{ name = 'formatter-interop'; passed = $record.passed; results = $formatterResults; log = $record.stdout })
+ if (-not $record.passed) { $failures.Add('Formatter interoperability failed.') }
+
+ $feed = Join-Path $packagedRoot 'feed'
+ $install = Join-Path $packagedRoot 'tool'
+ $packages = Join-Path $packagedRoot 'package-cache'
+ $cliHome = Join-Path $packagedRoot 'cli-home'
+ $httpCache = Join-Path $packagedRoot 'http-cache'
+ $workspace = Join-Path $packagedRoot 'installation workspace'
+ New-Item -ItemType Directory -Path $feed, $workspace -Force | Out-Null
+ $packageVersion = '0.0.0-editor-contract'
+ $null = Invoke-RequiredProcess 'pack-language-server' 'dotnet' @('pack', $projects['language-server'],
+ '--no-build', '--no-restore', '--configuration', $Configuration, '--output', $feed,
+ "-p:MinVerVersionOverride=$packageVersion") -TimeoutSeconds 180
+ $package = Join-Path $feed "SharpTS.LanguageServer.$packageVersion.nupkg"
+ if (-not (Test-Path -LiteralPath $package -PathType Leaf)) { throw "Expected exact package not found: $package" }
+ $nugetConfig = Join-Path $packagedRoot 'NuGet.Config'
+ $escapedFeed = [Security.SecurityElement]::Escape($feed)
+ " " |
+ Set-Content -LiteralPath $nugetConfig -Encoding utf8NoBOM
+ $isolatedEnvironment = @{
+ NUGET_PACKAGES = $packages; NUGET_HTTP_CACHE_PATH = $httpCache; DOTNET_CLI_HOME = $cliHome
+ DOTNET_CLI_TELEMETRY_OPTOUT = '1'; DOTNET_SKIP_FIRST_TIME_EXPERIENCE = '1'
+ }
+ $null = Invoke-RequiredProcess 'install-exact-language-server' 'dotnet' @('tool', 'install',
+ 'SharpTS.LanguageServer', '--version', $packageVersion, '--tool-path', $install,
+ '--configfile', $nugetConfig, '--no-cache') -WorkingDirectory $workspace -Environment $isolatedEnvironment -TimeoutSeconds 180
+ $shimName = if ($IsWindows) { 'sharpts-lsp.exe' } else { 'sharpts-lsp' }
+ $shim = Join-Path $install $shimName
+ $packagedResults = Join-Path $packagedRoot 'protocol'
+ $record = Invoke-BoundedProcess 'packaged-tool-protocol' $nodeCommand @((Join-Path $PSScriptRoot 'test-packaged-lsp.mjs'),
+ $shim, $packagedResults, $package, $packageVersion) -Environment $isolatedEnvironment -TimeoutSeconds 180
+ $suites.Add([ordered]@{ name = 'packaged-tool'; passed = $record.passed; results = $packagedResults
+ package = $package; sha256 = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash.ToLowerInvariant()
+ version = $packageVersion; shim = $shim; localOnlyConfig = $nugetConfig; log = $record.stdout })
+ if (-not $record.passed) { $failures.Add('Installed language-server tool smoke failed.') }
+ $script:evidence.completed = $failures.Count -eq 0
+}
+catch {
+ $failures.Add($_.Exception.Message)
+ $_ | Out-String | Set-Content -LiteralPath (Join-Path $runDirectory 'failure.txt') -Encoding utf8NoBOM
+}
+finally {
+ foreach ($name in @('tool', 'package-cache', 'cli-home', 'http-cache')) {
+ try { Remove-IsolatedDirectory (Join-Path $packagedRoot $name) }
+ catch { $failures.Add("Isolated tool cleanup failed: $($_.Exception.Message)") }
+ }
+ $env:PATH = $originalPath
+ $script:evidence.completed = $script:evidence.completed -and $failures.Count -eq 0
+ $script:evidence['finishedUtc'] = [DateTime]::UtcNow.ToString('o')
+ Save-Evidence
+}
+if (-not $script:evidence.completed) { throw "Editor contract failed. Evidence: $indexPath`n$($failures -join [Environment]::NewLine)" }
+Write-Host "Editor contract passed. Evidence: $indexPath"
diff --git a/scripts/test-formatter-interop.mjs b/scripts/test-formatter-interop.mjs
new file mode 100644
index 000000000..e6f754b84
--- /dev/null
+++ b/scripts/test-formatter-interop.mjs
@@ -0,0 +1,139 @@
+import assert from "node:assert/strict";
+import { spawn, spawnSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { LspStdioClient } from "./lib/lsp-stdio.mjs";
+
+const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const configuration = process.argv[2] ?? "Release";
+const output = path.resolve(process.argv[3] ?? path.join(repository, "artifacts/formatter-interop", crypto.randomUUID()));
+const sourceRoot = path.join(repository, "tests/fixtures/FormatterInterop");
+const formattedRoot = path.join(output, "formatted");
+const crlfRoot = path.join(output, "formatted-crlf");
+const prettier = path.join(repository, "tools/formatter-interop/node_modules/prettier/bin/prettier.cjs");
+const packageJson = JSON.parse(await readFile(path.join(repository, "tools/formatter-interop/package.json"), "utf8"));
+
+function run(command, args, input, timeout = 60_000) {
+ const result = spawnSync(command, args, {
+ cwd: repository, input, encoding: "utf8", timeout,
+ maxBuffer: 16 * 1024 * 1024, windowsHide: true,
+ });
+ if (result.error) throw new Error(`${command}: ${result.error.message}\n${result.stderr}\n${result.stdout}`);
+ assert.equal(result.status, 0, `${command} ${args.join(" ")}\n${result.stderr}\n${result.stdout}`);
+ return result.stdout;
+}
+
+async function runEvidence(args) {
+ const child = spawn("dotnet", args, { cwd: repository, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
+ let timedOut = false;
+ const timer = setTimeout(() => { timedOut = true; child.kill(); }, 300_000);
+ child.stdout.on("data", (data) => process.stdout.write(data));
+ child.stderr.on("data", (data) => process.stderr.write(data));
+ const code = await new Promise((resolve, reject) => {
+ child.once("error", reject);
+ child.once("close", resolve);
+ }).finally(() => clearTimeout(timer));
+ assert.equal(timedOut, false, "Compiler evidence exceeded five minutes");
+ assert.equal(code, 0, `Compiler evidence failed: dotnet ${args.join(" ")}`);
+}
+
+await mkdir(formattedRoot, { recursive: true });
+await rm(path.join(output, "summary.json"), { force: true });
+const actualVersion = run(process.execPath, [prettier, "--version"]).trim();
+assert.equal(actualVersion, packageJson.devDependencies.prettier, "Use npm ci with the checked-in lock");
+const fixtures = JSON.parse(await readFile(path.join(sourceRoot, "fixtures.json"), "utf8"));
+const results = [];
+for (const fixture of fixtures) {
+ const sourcePath = path.resolve(sourceRoot, fixture.path);
+ assert.ok(sourcePath.startsWith(sourceRoot + path.sep), "Fixture path must stay within its source root");
+ const text = await readFile(sourcePath, "utf8");
+ const args = [prettier, "--stdin-filepath", sourcePath];
+ const configPath = run(process.execPath, [prettier, "--find-config-path", sourcePath]).trim();
+ assert.equal(path.resolve(repository, configPath), path.join(sourceRoot, ".prettierrc.json"),
+ `${fixture.name}: real source filename must discover the fixture configuration`);
+ const formatted = run(process.execPath, args, text.replace(/\r\n/g, "\n"));
+ assert.ok(formatted.length > 0, `${fixture.name}: formatter returned empty output`);
+ assert.ok(!formatted.includes("\r\n"), `${fixture.name}: endOfLine auto must retain LF`);
+ assert.equal(run(process.execPath, args, formatted), formatted, `${fixture.name}: format is not idempotent`);
+ const destination = path.join(formattedRoot, fixture.path);
+ await mkdir(path.dirname(destination), { recursive: true });
+ await writeFile(destination, formatted, "utf8");
+ const crlfInput = text.replace(/\r?\n/g, "\r\n");
+ const crlfFormatted = run(process.execPath, args, crlfInput);
+ assert.ok(!/(? 0, "No formatter fixtures were executed");
+
+const harness = path.join(sourceRoot, "bin", configuration, "net10.0/SharpTS.FormatterInteropEvidence.dll");
+const semanticReport = path.join(output, "semantics.json");
+await runEvidence([harness, sourceRoot, formattedRoot, semanticReport]);
+const crlfSemanticReport = path.join(output, "semantics-crlf.json");
+await runEvidence([harness, sourceRoot, crlfRoot, crlfSemanticReport]);
+
+const server = path.join(repository, "src/SharpTS.LanguageServer/bin", configuration, "net10.0/SharpTS.LanguageServer.dll");
+// Keep existing GUI metadata discovery inside the disposable protocol workspace.
+await writeFile(path.join(output, "Smoke.csproj"), ' \n');
+await mkdir(path.join(output, "obj/@sharpts/gui"), { recursive: true });
+await writeFile(path.join(output, "obj/@sharpts/gui/control-docs.generated.json"), '{"controls":[]}\n');
+const protocol = [];
+for (const mode of ["full", "interop-only"]) {
+ const client = new LspStdioClient("dotnet", [server, "--language-features", mode], { cwd: output });
+ try {
+ const textDocument = Object.fromEntries([
+ "hover", "completion", "signatureHelp", "definition", "references", "documentSymbol", "rename",
+ "formatting", "rangeFormatting", "onTypeFormatting",
+ ].map((name) => [name, { dynamicRegistration: false }]));
+ textDocument.rename.prepareSupport = true;
+ const initialized = await client.request("initialize", {
+ processId: process.pid, rootUri: pathToFileURL(output).href,
+ capabilities: { textDocument },
+ });
+ assert.ok(!initialized.error, JSON.stringify(initialized.error));
+ const caps = initialized.result.capabilities;
+ assert.ok(caps.hoverProvider && caps.completionProvider && caps.signatureHelpProvider,
+ "Positive interop capabilities must be present; no-format verification must not be vacuous");
+ assert.equal(caps.definitionProvider != null && caps.definitionProvider !== false, mode === "full");
+ assert.equal(caps.renameProvider != null && caps.renameProvider !== false, mode === "full");
+ for (const provider of ["documentFormattingProvider", "documentRangeFormattingProvider", "documentOnTypeFormattingProvider"]) {
+ assert.ok(caps[provider] == null || caps[provider] === false, `${mode}: ${provider} must remain absent`);
+ }
+ client.notify("initialized", {});
+ const uri = pathToFileURL(path.join(output, `interop-${mode}.ts`)).href;
+ client.notify("textDocument/didOpen", { textDocument: {
+ uri, languageId: "typescript", version: 1,
+ text: '@DotNetType("System.Text.StringBuilder")\ndeclare class Builder {}\n',
+ } });
+ const hover = await client.request("textDocument/hover", { textDocument: { uri }, position: { line: 0, character: 5 } });
+ assert.ok(!hover.error && JSON.stringify(hover.result).includes("StringBuilder"), `${mode}: interop hover must work`);
+ for (const method of ["formatting", "rangeFormatting", "onTypeFormatting"]) {
+ const formatting = await client.request(`textDocument/${method}`, {
+ textDocument: { uri }, options: { tabSize: 2, insertSpaces: true },
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } },
+ position: { line: 0, character: 10 }, ch: ";",
+ });
+ assert.equal(formatting.error?.code, -32601, `${mode}: ${method} must not be served`);
+ }
+ assert.ok(!client.registeredMethods.some((method) => /textDocument\/(?:formatting|rangeFormatting|onTypeFormatting)$/.test(method)),
+ `${mode}: no formatting method may be dynamically registered`);
+ protocol.push({ mode, capabilities: caps, interopHover: true, formattingMethodsNotFound: true });
+ } finally {
+ await client.close();
+ }
+}
+
+const report = { completed: true, prettier: actualVersion, node: process.version,
+ dotnet: run("dotnet", ["--version"]).trim(), configuration, fixtures: results,
+ semanticReport, crlfSemanticReport, protocol };
+await writeFile(path.join(output, "summary.json"), JSON.stringify(report, null, 2) + "\n");
+console.log(`Formatter interoperability passed: ${results.length} fixtures; both LSP modes. ${output}`);
diff --git a/scripts/test-formatter-interop.ps1 b/scripts/test-formatter-interop.ps1
new file mode 100644
index 000000000..eeb6bd919
--- /dev/null
+++ b/scripts/test-formatter-interop.ps1
@@ -0,0 +1,42 @@
+[CmdletBinding()]
+param(
+ [ValidateSet('Debug', 'Release')]
+ [string]$Configuration = 'Release',
+ [string]$ResultsDirectory = 'artifacts/formatter-interop',
+ [switch]$NoBuild
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$repositoryRoot = Split-Path -Parent $PSScriptRoot
+$formatter = Join-Path $repositoryRoot 'tools/formatter-interop/node_modules/prettier/bin/prettier.cjs'
+if (-not (Test-Path -LiteralPath $formatter)) {
+ throw 'Install the optional pinned formatter first: npm ci --prefix tools/formatter-interop --ignore-scripts'
+}
+$results = [IO.Path]::GetFullPath($ResultsDirectory, $repositoryRoot)
+$runDirectory = Join-Path $results ([Guid]::NewGuid().ToString('N'))
+New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null
+
+Push-Location $repositoryRoot
+try {
+ if (-not $NoBuild) {
+ foreach ($project in @('tests/fixtures/FormatterInterop/SharpTS.FormatterInteropEvidence.csproj',
+ 'src/SharpTS.LanguageServer/SharpTS.LanguageServer.csproj')) {
+ & dotnet build $project -c $Configuration --nologo --verbosity minimal
+ if ($LASTEXITCODE -ne 0) { throw "Build failed: $project" }
+ }
+ }
+ & node (Join-Path $PSScriptRoot 'test-formatter-interop.mjs') $Configuration $runDirectory
+ if ($LASTEXITCODE -ne 0) { throw "Formatter interoperability failed. Artifacts: $runDirectory" }
+ $summary = Get-Content -LiteralPath (Join-Path $runDirectory 'summary.json') -Raw | ConvertFrom-Json
+ if (-not $summary.completed -or $summary.fixtures.Count -eq 0 -or $summary.protocol.Count -ne 2) {
+ throw 'Formatter smoke did not execute the required nonempty fixture and protocol checks.'
+ }
+}
+catch {
+ $_ | Out-String | Set-Content -LiteralPath (Join-Path $runDirectory 'failure.txt')
+ throw
+}
+finally {
+ Pop-Location
+}
diff --git a/scripts/test-github-workflows.ps1 b/scripts/test-github-workflows.ps1
index 691293fb4..c4fff23f3 100644
--- a/scripts/test-github-workflows.ps1
+++ b/scripts/test-github-workflows.ps1
@@ -79,7 +79,7 @@ foreach ($requiredText in @(
$errors.Add("ci.yml is missing change-routing contract text: $requiredText")
}
}
-foreach ($jobName in @('build', 'standalone', 'typescript-conformance', 'dap-macos-smoke', 'aot-ratchet', 'native-aot-compile-smoke')) {
+foreach ($jobName in @('build', 'standalone', 'typescript-conformance', 'editor-contract', 'dap-macos-smoke', 'aot-ratchet', 'native-aot-compile-smoke')) {
$job = Get-WorkflowJob $ci $jobName 'ci.yml'
if (-not $job.Contains('needs: workflow-policy', [StringComparison]::Ordinal) -or
-not $job.Contains("if: needs.workflow-policy.outputs.mode == 'full'", [StringComparison]::Ordinal)) {
@@ -127,6 +127,21 @@ foreach ($requiredText in @('./scripts/test-typescript-conformance.ps1', 'if: al
$errors.Add("ci.yml TypeScript gate is missing execution/artifact budget contract: $requiredText")
}
}
+$editorJob = Get-WorkflowJob $ci 'editor-contract' 'ci.yml'
+foreach ($requiredText in @('os: [ubuntu-24.04, windows-2025]', 'timeout-minutes: 30',
+ 'global-json-file: global.json', 'node-version-file: .node-version',
+ 'cache-dependency-path: tools/formatter-interop/package-lock.json',
+ 'npm ci --prefix tools/formatter-interop --ignore-scripts',
+ './scripts/test-editor-contract.ps1 -NoRestore -NoFormatterInstall',
+ 'timeout-minutes: 25', 'if: always()', 'artifacts/editor-contract/', 'retention-days: 7')) {
+ if (-not $editorJob.Contains($requiredText, [StringComparison]::Ordinal)) {
+ $errors.Add("ci.yml editor contract is missing pinned execution/evidence contract: $requiredText")
+ }
+}
+if ($ciGateJob -notmatch '(?m)^ needs: \[[^\r\n]*\beditor-contract\b' -or
+ $ciGateJob -notmatch '(?m)^ \$heavy = @\([^\r\n]*''editor-contract''') {
+ $errors.Add('ci.yml Gate must require editor-contract in the full route and skip it in lightweight routes.')
+}
foreach ($requiredText in @('CHANGE_MODE:', "'csharp-trivia-only'", "'docs-only'", "'lightweight-validation'")) {
if (-not $ciGateJob.Contains($requiredText, [StringComparison]::Ordinal)) {
$errors.Add("ci.yml Gate is missing routed-result validation text: $requiredText")
diff --git a/scripts/test-member-references.mjs b/scripts/test-member-references.mjs
new file mode 100644
index 000000000..af4e25da4
--- /dev/null
+++ b/scripts/test-member-references.mjs
@@ -0,0 +1,244 @@
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, writeFile, unlink } from "node:fs/promises";
+import { dirname, resolve, join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { setTimeout as delay } from "node:timers/promises";
+import { LspStdioClient } from "./lib/lsp-stdio.mjs";
+
+const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const server = resolve(repository, process.argv[2] ?? "src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll");
+await mkdir(join(repository, "artifacts/member-reference-smoke"), { recursive: true });
+const workspace = await mkdtemp(join(repository, "artifacts/member-reference-smoke/run-"));
+const owner = "export class Base {\r\n" +
+ " /* 😀 */ /*decl*/field: number = 1;\r\n" +
+ " constructor(public /*parameter*/token: number = 1) {}\r\n" +
+ " read(): number { return this./*self*/field; }\r\n}\r\n" +
+ "export class Derived extends Base {}\r\n" +
+ "export class Unrelated { field: number = 0; }\r\n";
+const entry = "import { Derived } from '../lib/model';\r\n" +
+ "const item = new Derived(1);\r\n" +
+ "const /*lexicalDeclaration*/lexical = 1;\r\n" +
+ "/* 😀 */ item./*read*/field;\r\n" +
+ "item./*write*/field = 2;\r\n" +
+ "item?./*optional*/field;\r\n" +
+ "item./*update*/field++;\r\n" +
+ "item./*token*/token;\r\n" +
+ "/*lexicalUse*/lexical;\r\n";
+const reverse = "import { Base } from '../lib/model';\r\n" +
+ "const closed = new Base(1);\r\n" +
+ "/* 😀 */ closed./*reverse*/field;\r\nclosed./*reverseToken*/token;\r\n";
+const options = { noLib: true, types: [] };
+await mkdir(join(workspace, "lib"));
+await mkdir(join(workspace, "app"));
+await writeFile(join(workspace, "lib/tsconfig.json"), JSON.stringify({ compilerOptions: options, files: ["model.ts"] }));
+await writeFile(join(workspace, "app/tsconfig.json"), JSON.stringify({
+ compilerOptions: options, files: ["main.ts", "reverse.ts"], references: [{ path: "../lib" }],
+}));
+await writeFile(join(workspace, "lib/model.ts"), owner);
+await writeFile(join(workspace, "app/main.ts"), entry);
+await writeFile(join(workspace, "app/reverse.ts"), reverse);
+const uri = (name) => pathToFileURL(join(workspace, name)).href;
+const canonicalUri = (value) => process.platform === "win32" ? value.toLowerCase() : value;
+const sameUri = (left, right) => canonicalUri(left) === canonicalUri(right);
+const positionAt = (text, offset) => {
+ const prefix = text.slice(0, offset);
+ return { line: prefix.split("\n").length - 1, character: offset - prefix.lastIndexOf("\n") - 1 };
+};
+function location(name, text, marker, spelling) {
+ const comment = "/*" + marker + "*/";
+ const offset = text.indexOf(comment);
+ assert.notEqual(offset, -1, "Missing marker " + marker);
+ const start = offset + comment.length;
+ assert.equal(text.slice(start, start + spelling.length), spelling);
+ return { uri: uri(name), range: { start: positionAt(text, start), end: positionAt(text, start + spelling.length) } };
+}
+function order(left, right) {
+ const a = canonicalUri(left.uri), b = canonicalUri(right.uri);
+ return (a < b ? -1 : a > b ? 1 : 0) || left.range.start.line - right.range.start.line ||
+ left.range.start.character - right.range.start.character || left.range.end.line - right.range.end.line ||
+ left.range.end.character - right.range.end.character;
+}
+const key = (value) => JSON.stringify({ uri: canonicalUri(value.uri), range: value.range });
+function assertLocations(result, expected) {
+ const actual = result ?? [];
+ assert.ok(Array.isArray(actual));
+ assert.equal(new Set(actual.map(key)).size, actual.length, "Complete source spans are deduplicated");
+ assert.deepEqual(actual.map(key), [...actual].sort(order).map(key), "References have deterministic URI/range order");
+ assert.deepEqual(actual.map(key), [...expected].sort(order).map(key), "Exact UTF-16/CRLF member token locations");
+ return actual;
+}
+function fieldLocations(ownerText = owner, entryText = entry, includeDeclaration = true) {
+ return [
+ ...(includeDeclaration ? [location("lib/model.ts", ownerText, "decl", "field")] : []),
+ location("lib/model.ts", ownerText, "self", "field"),
+ ...["read", "write", "optional", "update"].map((marker) => location("app/main.ts", entryText, marker, "field")),
+ location("app/reverse.ts", reverse, "reverse", "field"),
+ ];
+}
+const tokenLocations = (ownerText = owner) => [
+ location("lib/model.ts", ownerText, "parameter", "token"),
+ location("app/main.ts", entry, "token", "token"),
+ location("app/reverse.ts", reverse, "reverseToken", "token"),
+];
+const lexicalLocations = (text = entry) => ["lexicalDeclaration", "lexicalUse"].map((marker) =>
+ location("app/main.ts", text, marker, "lexical"));
+const requestAt = (marker = "read", text = entry, spelling = "field") => ({
+ textDocument: { uri: uri("app/main.ts") },
+ position: { ...location("app/main.ts", text, marker, spelling).range.start,
+ character: location("app/main.ts", text, marker, spelling).range.start.character + 1 },
+});
+async function request(client, method, params) {
+ const response = await client.request(method, params);
+ assert.equal(response.error, undefined, method + ": " + JSON.stringify(response.error));
+ return response.result;
+}
+async function diagnostics(client, start, name, version) {
+ const deadline = performance.now() + 20_000;
+ while (performance.now() < deadline) {
+ const found = client.notifications.slice(start).find((item) => item.method === "textDocument/publishDiagnostics" &&
+ sameUri(item.params.uri, uri(name)) && item.params.version === version);
+ if (found) return found.params.diagnostics;
+ await delay(25);
+ }
+ throw new Error("No diagnostics barrier for " + name + " version " + version);
+}
+async function initialize(client) {
+ const result = await request(client, "initialize", {
+ processId: process.pid, rootUri: pathToFileURL(workspace).href,
+ workspaceFolders: [{ uri: pathToFileURL(workspace).href, name: "member-references" }],
+ capabilities: {
+ workspace: { didChangeWatchedFiles: { dynamicRegistration: true }, workspaceFolders: true },
+ textDocument: { references: {}, rename: { prepareSupport: true }, publishDiagnostics: { versionSupport: true } },
+ },
+ });
+ client.notify("initialized", {});
+ return result.capabilities;
+}
+
+const report = { workspace, server, full: {}, interopOnly: {} };
+for (const mode of ["full", "interop-only"]) {
+ const client = new LspStdioClient("dotnet", [server, "--language-features", mode, "--diagnostics",
+ mode === "full" ? "all" : "sharpts-only"], { cwd: workspace });
+ let failure;
+ let phase = "initialize";
+ const runCase = async (name, action) => {
+ phase = name;
+ try { return await action(); }
+ catch (error) { throw new Error(`${mode}: ${name}: ${error.message}`, { cause: error }); }
+ };
+ try {
+ const capabilities = await initialize(client);
+ assert.equal(Boolean(capabilities.referencesProvider), mode === "full");
+ assert.equal(Boolean(capabilities.renameProvider), mode === "full");
+ client.notify("textDocument/didOpen", {
+ textDocument: { uri: uri("app/main.ts"), languageId: "typescript", version: 1, text: entry },
+ });
+ await diagnostics(client, 0, "app/main.ts", 1);
+ if (mode === "interop-only") {
+ await runCase("references-method-is-unregistered", async () => {
+ const response = await client.request("textDocument/references", { ...requestAt(), context: { includeDeclaration: true } });
+ assert.equal(response.error?.code, -32601);
+ });
+ report.interopOnly = { capabilityAbsent: true, referencesError: -32601 };
+ continue;
+ }
+ const references = (includeDeclaration = true, at = requestAt()) => request(client, "textDocument/references",
+ { ...at, context: { includeDeclaration } });
+ await runCase("two-projects-closed-reverse-importer-and-declaration-filter", async () => {
+ const withDeclaration = assertLocations(await references(), fieldLocations());
+ assertLocations(await references(false), fieldLocations(owner, entry, false));
+ const repeated = await Promise.all(Array.from({ length: 4 }, () => references()));
+ repeated.forEach((result) => assertLocations(result, fieldLocations()));
+ assert.ok(withDeclaration.some((value) => sameUri(value.uri, uri("app/reverse.ts"))), "Closed reverse importer is included");
+ report.full.referenceCount = withDeclaration.length;
+ });
+ await runCase("complete-member-references-do-not-enable-public-or-parameter-property-rename", async () => {
+ assertLocations(await references(true, requestAt("token", entry, "token")), tokenLocations());
+ for (const at of [requestAt(), requestAt("token", entry, "token")]) {
+ assert.equal(await request(client, "textDocument/prepareRename", at), null);
+ assert.equal(await request(client, "textDocument/rename", { ...at, newName: "nextMember" }), null);
+ }
+ });
+ await runCase("dirty-owner-anchor-and-source-ranges-move-together", async () => {
+ const dirty = "// dirty owner version one\r\n" + owner;
+ const openStart = client.notifications.length;
+ client.notify("textDocument/didOpen", { textDocument: {
+ uri: uri("lib/model.ts"), languageId: "typescript", version: 1, text: dirty,
+ } });
+ assert.deepEqual(await diagnostics(client, openStart, "lib/model.ts", 1), []);
+ assertLocations(await references(), fieldLocations(dirty));
+ const changed = "// dirty owner version two\r\n" + dirty;
+ const changeStart = client.notifications.length;
+ client.notify("textDocument/didChange", { textDocument: { uri: uri("lib/model.ts"), version: 2 },
+ contentChanges: [{ text: changed }] });
+ assert.deepEqual(await diagnostics(client, changeStart, "lib/model.ts", 2), []);
+ assertLocations(await references(), fieldLocations(changed));
+ const removed = changed.replaceAll("field", "renamedField");
+ const removeStart = client.notifications.length;
+ client.notify("textDocument/didChange", { textDocument: { uri: uri("lib/model.ts"), version: 3 },
+ contentChanges: [{ text: removed }] });
+ assert.deepEqual(await diagnostics(client, removeStart, "lib/model.ts", 3), []);
+ assertLocations(await references(), []);
+ const closeStart = client.notifications.length;
+ client.notify("textDocument/didClose", { textDocument: { uri: uri("lib/model.ts") } });
+ await diagnostics(client, closeStart, "app/main.ts", 1);
+ assertLocations(await references(), fieldLocations());
+ report.full.dirtyOwnerVersions = [1, 2, 3];
+ report.full.closeRestoresDisk = true;
+ });
+ await runCase("incomplete-config-discovery-refuses-member-results-and-preserves-lexical-results", async () => {
+ assertLocations(await references(true, requestAt("lexicalUse", entry, "lexical")), lexicalLocations());
+ await mkdir(join(workspace, "broken"));
+ const broken = join(workspace, "broken/tsconfig.json");
+ await writeFile(broken, "{ invalid config");
+ const changedStart = client.notifications.length;
+ client.notify("workspace/didChangeWatchedFiles", { changes: [{ uri: uri("broken/tsconfig.json"), type: 1 }] });
+ await diagnostics(client, changedStart, "app/main.ts", 1);
+ assertLocations(await references(), []);
+ assertLocations(await references(true, requestAt("lexicalUse", entry, "lexical")), lexicalLocations());
+ await unlink(broken);
+ const restoredStart = client.notifications.length;
+ client.notify("workspace/didChangeWatchedFiles", { changes: [{ uri: uri("broken/tsconfig.json"), type: 3 }] });
+ await diagnostics(client, restoredStart, "app/main.ts", 1);
+ assertLocations(await references(), fieldLocations());
+ report.full.incompleteMemberRefusalPreservesLexical = true;
+ });
+ await runCase("wire-cancellation-with-surviving-peer-and-fresh-request", async () => {
+ client.notify("workspace/didChangeWatchedFiles", { changes: [{ uri: uri("lib/model.ts"), type: 2 }] });
+ const cancelled = client.beginRequest("textDocument/references", { ...requestAt(), context: { includeDeclaration: true } });
+ const peer = references();
+ void peer.catch(() => {});
+ const cancelSent = cancelled.cancel();
+ const response = await cancelled.response;
+ let cancellationOutcome;
+ if (response.error) {
+ assert.equal(response.error.code, -32800);
+ cancellationOutcome = "request-cancelled";
+ } else if ((response.result ?? []).length === 0) cancellationOutcome = "no-result-during-invalidation";
+ else {
+ assertLocations(response.result, fieldLocations());
+ cancellationOutcome = "completed-before-cancel";
+ }
+ assert.ok(cancelSent, "Actual $/cancelRequest notification was sent");
+ assertLocations(await peer, fieldLocations());
+ assertLocations(await references(), fieldLocations());
+ report.full.cancellation = { cancelSent, cancellationOutcome, peerAndFreshSurvived: true };
+ });
+ report.full = { ...report.full, twoConfiguredProjects: true, projectReference: true, closedReverseImporter: true,
+ exactUtf16CrLfRanges: true, deterministicDeduplicated: true, includeDeclaration: [true, false],
+ publicAndParameterPropertyRenameDenied: true };
+ } catch (error) {
+ failure = error;
+ await writeFile(join(workspace, "failure.json"), JSON.stringify({ mode, phase, message: error.message,
+ notifications: client.notifications.slice(-12), report }, null, 2) + "\n").catch(() => {});
+ throw error;
+ } finally {
+ try { await client.close(); }
+ catch (error) {
+ if (!failure) throw error;
+ console.error("Cleanup after test failure: " + error.message);
+ }
+ }
+}
+await writeFile(join(workspace, "result.json"), JSON.stringify(report, null, 2) + "\n");
+console.log(JSON.stringify(report, null, 2));
diff --git a/scripts/test-packaged-lsp.mjs b/scripts/test-packaged-lsp.mjs
new file mode 100644
index 000000000..6f6b569c6
--- /dev/null
+++ b/scripts/test-packaged-lsp.mjs
@@ -0,0 +1,183 @@
+import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
+import { mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
+import { dirname, isAbsolute, join, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import { setTimeout as delay } from "node:timers/promises";
+import { LspStdioClient } from "./lib/lsp-stdio.mjs";
+
+// The caller installs an exact local nupkg first. This command deliberately accepts
+// only its generated executable shim, never a repository DLL or a global tool name.
+const [shimArgument, outputArgument, packageArgument, packageVersion] = process.argv.slice(2);
+assert.ok(shimArgument && outputArgument && packageArgument && packageVersion,
+ "Usage: node scripts/test-packaged-lsp.mjs ");
+assert.ok(isAbsolute(shimArgument), "Pass the exact absolute installed-tool shim path");
+const shim = await realpath(shimArgument);
+assert.ok((await stat(shim)).isFile(), "Installed shim must be an executable file");
+assert.match(shim, /[/\\]sharpts-lsp(?:\.exe)?$/i, "Launch the installed sharpts-lsp command");
+const toolDirectory = dirname(shim);
+assert.ok((await stat(join(toolDirectory, ".store", "sharpts.languageserver", packageVersion))).isDirectory(),
+ "Shim must belong to the isolated installation of this exact package version");
+const output = resolve(outputArgument);
+const packagePath = resolve(packageArgument);
+const workspace = join(output, "workspace with spaces");
+await mkdir(workspace, { recursive: true });
+const source = "import { StringBuilder as SB } from 'dotnet:System.Text.StringBuilder';\r\n" +
+ "const /*declaration*/local: number = 123;\r\n/* 😀 */ /*ordinary*/local;\r\n" +
+ "const builder = new SB();\r\nbuilder./*clr*/append('x');\r\n";
+const decorator = '@DotNetType("System.String")\ndeclare class NetString {}\n';
+await writeFile(join(workspace, "tsconfig.json"), JSON.stringify({
+ compilerOptions: { noLib: true, types: [], experimentalDecorators: true }, include: ["*.ts"],
+}));
+await writeFile(join(workspace, "main.ts"), source);
+await writeFile(join(workspace, "decorator.ts"), decorator);
+const uri = (name) => pathToFileURL(join(workspace, name)).href;
+const positionAt = (offset) => {
+ const prefix = source.slice(0, offset);
+ return { line: prefix.split("\n").length - 1, character: offset - prefix.lastIndexOf("\n") - 1 };
+};
+const position = (marker) => ({ textDocument: { uri: uri("main.ts") },
+ position: positionAt(source.indexOf(`/*${marker}*/`) + marker.length + 5) });
+const present = (value) => value !== undefined && value !== null && value !== false;
+const sameUri = (left, right) => process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
+const forbiddenProviders = ["inlayHintProvider", "semanticTokensProvider", "foldingRangeProvider",
+ "documentFormattingProvider", "documentRangeFormattingProvider", "documentOnTypeFormattingProvider"];
+const forbiddenMethod = /^textDocument\/(?:inlayHint|semanticTokens(?:\/|$)|foldingRange|formatting|rangeFormatting|onTypeFormatting)/;
+const report = { completed: false, workspace, node: process.version, package: {
+ path: packagePath, version: packageVersion,
+ sha256: createHash("sha256").update(await readFile(packagePath)).digest("hex"),
+}, shim, isolatedToolDirectory: toolDirectory, clients: [] };
+
+async function request(client, method, params) {
+ const response = await client.request(method, params);
+ assert.equal(response.error, undefined, `${method}: ${JSON.stringify(response.error)}`);
+ return response.result;
+}
+
+async function diagnosticsAfter(client, start, predicate) {
+ const deadline = performance.now() + 20_000;
+ while (performance.now() < deadline) {
+ const found = client.notifications.slice(start).find((notification) =>
+ notification.method === "textDocument/publishDiagnostics" &&
+ sameUri(notification.params.uri, uri("diagnostic.ts")) &&
+ notification.params.version === 1 && predicate(notification.params.diagnostics));
+ if (found) return found.params.diagnostics;
+ await delay(25);
+ }
+ throw new Error(`No expected diagnostic-mode publication: ${JSON.stringify(client.notifications.slice(-12))}`);
+}
+
+try {
+ for (const mode of ["full", "interop-only"]) {
+ // Explicit all in interop-only proves that checking for diagnostics cannot
+ // silently enable ordinary request providers. Coexistence's sharpts-only
+ // startup recipe is exercised separately by all six child protocol suites.
+ const args = ["--language-features", mode, "--diagnostics", "all"];
+ const client = new LspStdioClient(shim, args, { cwd: workspace, timeoutMs: 30_000 });
+ const result = { mode, command: [shim, ...args], cwd: workspace, cases: [] };
+ report.clients.push(result);
+ let failure;
+ let phase = "initialize";
+ try {
+ const initialized = await request(client, "initialize", { processId: process.pid,
+ rootUri: pathToFileURL(workspace).href,
+ workspaceFolders: [{ uri: pathToFileURL(workspace).href, name: "installed-tool" }],
+ capabilities: { textDocument: {
+ // Feature objects are advertised; all their nested presentation options
+ // are omitted. An entirely absent feature need not be advertised back.
+ ...Object.fromEntries(["hover", "completion", "signatureHelp", "codeAction", "definition",
+ "references", "rename", "documentSymbol"].map((feature) => [feature, {}])),
+ publishDiagnostics: { versionSupport: true },
+ } },
+ });
+ const capabilities = initialized.capabilities;
+ result.capabilities = capabilities;
+ for (const provider of ["hoverProvider", "completionProvider", "signatureHelpProvider", "codeActionProvider"])
+ assert.ok(present(capabilities[provider]), `${mode}: retained ${provider}`);
+ for (const provider of ["definitionProvider", "referencesProvider", "renameProvider", "documentSymbolProvider"])
+ assert.equal(present(capabilities[provider]), mode === "full", `${mode}: ${provider}`);
+ for (const provider of forbiddenProviders)
+ assert.ok(!present(capabilities[provider]), `${mode}: unsupported ${provider}`);
+ client.notify("initialized", {});
+ for (const [name, text] of [["main.ts", source], ["decorator.ts", decorator]])
+ client.notify("textDocument/didOpen", { textDocument: { uri: uri(name), languageId: "typescript", version: 1, text } });
+ result.cases.push("initialize-with-empty-feature-capabilities");
+
+ phase = "ordinary-feature-mode";
+ const hover = await request(client, "textDocument/hover", position("ordinary"));
+ const definition = await client.request("textDocument/definition", position("ordinary"));
+ if (mode === "full") {
+ assert.ok(hover && /number/.test(JSON.stringify(hover)), "Installed full tool returns ordinary semantic hover");
+ assert.equal(definition.error, undefined);
+ const locations = Array.isArray(definition.result) ? definition.result : [definition.result];
+ assert.equal(locations.length, 1);
+ assert.ok(sameUri(locations[0].uri, uri("main.ts")));
+ const start = source.indexOf("/*declaration*/") + "/*declaration*/".length;
+ assert.deepEqual(locations[0].range, { start: positionAt(start), end: positionAt(start + "local".length) });
+ } else {
+ assert.equal(hover, null, "Installed interop-only tool has no ordinary hover fallback");
+ assert.equal(definition.error?.code, -32601, "Interop-only navigation remains unregistered");
+ }
+ result.cases.push("ordinary-feature-mode");
+
+ phase = "retained-clr-and-decorator-interop";
+ const clr = await request(client, "textDocument/hover", position("clr"));
+ assert.ok(clr && /append/i.test(JSON.stringify(clr)), "Installed tool retains CLR usage hover");
+ const decorated = await request(client, "textDocument/hover", {
+ textDocument: { uri: uri("decorator.ts") }, position: { line: 0, character: 5 },
+ });
+ assert.ok(decorated && /System\.String/.test(JSON.stringify(decorated)), "Installed tool retains decorator hover");
+ result.cases.push("retained-clr-and-decorator-interop");
+
+ phase = "live-diagnostics-mode-keeps-initialized-feature-mode";
+ let notificationStart = client.notifications.length;
+ client.notify("textDocument/didOpen", { textDocument: { uri: uri("diagnostic.ts"),
+ languageId: "typescript", version: 1, text: "const wrong: number = 'ordinary type error';\n" } });
+ await diagnosticsAfter(client, notificationStart, (items) => items.some((item) => item.severity === 1));
+ for (const diagnostics of ["off", "sharpts-only", "all"]) {
+ notificationStart = client.notifications.length;
+ client.notify("workspace/didChangeConfiguration", { settings: { sharpts: { diagnostics } } });
+ await diagnosticsAfter(client, notificationStart, (items) => diagnostics === "all"
+ ? items.some((item) => item.severity === 1) : items.length === 0);
+ }
+ const afterConfiguration = await request(client, "textDocument/hover", position("ordinary"));
+ if (mode === "interop-only") {
+ assert.equal(afterConfiguration, null, "diagnostics=all must not enable ordinary hover");
+ const refused = await client.request("textDocument/definition", position("ordinary"));
+ assert.equal(refused.error?.code, -32601, "Diagnostic changes cannot register ordinary navigation");
+ } else assert.ok(afterConfiguration, "Standalone ordinary requests remain available after diagnostic changes");
+ result.cases.push("explicit-all-and-live-diagnostics-mode-independent-of-feature-mode");
+
+ phase = "unimplemented-methods-and-surviving-request";
+ for (const method of ["inlayHint", "semanticTokens/full", "foldingRange", "formatting", "rangeFormatting", "onTypeFormatting"]) {
+ const response = await client.request(`textDocument/${method}`, { ...position("ordinary"),
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
+ options: { tabSize: 2, insertSpaces: true }, ch: ";",
+ });
+ assert.equal(response.error?.code, -32601, `${mode}: ${method} is unregistered`);
+ }
+ assert.ok(!client.registeredMethods.some((method) => forbiddenMethod.test(method)),
+ "Unsupported features must not appear through subsequent registration");
+ if (mode === "interop-only")
+ assert.ok(!client.registeredMethods.some((method) => /^textDocument\/(?:definition|references|rename|documentSymbol)$/.test(method)));
+ assert.ok(await request(client, "textDocument/hover", position("clr")), "Server survives unregistered requests");
+ result.registeredMethods = [...client.registeredMethods];
+ result.cases.push("unimplemented-methods-and-surviving-request");
+ } catch (error) {
+ failure = error;
+ await writeFile(join(output, "failure.json"), JSON.stringify({ mode, phase, message: error.message,
+ stack: error.stack, notifications: client.notifications.slice(-12), report }, null, 2) + "\n");
+ throw error;
+ } finally {
+ try { await client.close(); result.cases.push("shutdown-and-exit"); }
+ catch (error) {
+ if (!failure) throw error;
+ console.error(`Cleanup after packaged-tool failure: ${error.message}`);
+ }
+ }
+ }
+ report.completed = true;
+} finally {
+ await writeFile(join(output, "result.json"), JSON.stringify(report, null, 2) + "\n");
+}
+console.log(JSON.stringify(report, null, 2));
diff --git a/scripts/test-private-rename.mjs b/scripts/test-private-rename.mjs
new file mode 100644
index 000000000..fd90f9a58
--- /dev/null
+++ b/scripts/test-private-rename.mjs
@@ -0,0 +1,323 @@
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, writeFile, unlink } from "node:fs/promises";
+import { dirname, resolve, join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { setTimeout as delay } from "node:timers/promises";
+import { LspStdioClient } from "./lib/lsp-stdio.mjs";
+
+const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const server = resolve(repository, process.argv[2] ?? "src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll");
+await mkdir(join(repository, "artifacts/private-rename-smoke"), { recursive: true });
+const workspace = await mkdtemp(join(repository, "artifacts/private-rename-smoke/run-"));
+await mkdir(join(workspace, "project"));
+await writeFile(join(workspace, "project/tsconfig.json"), JSON.stringify({
+ compilerOptions: { noLib: true, types: [] }, include: ["*.ts"],
+}));
+const base = "class Box {\n" +
+ " /* 😀 */ /*declaration*/#old: number = 1;\n" +
+ " static #taken: number = 2;\n" +
+ " #bump(delta: number): number { this./*write*/#old = delta; return this./*read*/#old; }\n" +
+ " run(candidate: object): number {\n" +
+ " const read = () => this./*closure*/#old;\n" +
+ " if (/*brand*/#old in candidate) return this./*brandRead*/#old;\n" +
+ " return read();\n }\n" +
+ " invoke(): number { return this.#bump(3); }\n}\n" +
+ "class Other { #old: number = 9; read(): number { return this.#old; } }\n" +
+ "const text = '#old'; // #old is text, not an occurrence\n" +
+ "const box = new Box(); box.invoke(); box.run(box);\n";
+const selectedMarkers = ["declaration", "write", "read", "closure", "brand", "brandRead"];
+await writeFile(join(workspace, "project/main.ts"), base);
+const uri = (name = "project/main.ts") => pathToFileURL(join(workspace, name)).href;
+const sameUri = (left, right) => process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
+const positionAt = (text, offset) => {
+ const prefix = text.slice(0, offset);
+ return { line: prefix.split("\n").length - 1, character: offset - prefix.lastIndexOf("\n") - 1 };
+};
+function offsetAt(text, position) {
+ let offset = 0;
+ for (let line = 0; line < position.line; line++) {
+ const end = text.indexOf("\n", offset);
+ assert.notEqual(end, -1, "Edit line is inside captured text");
+ offset = end + 1;
+ }
+ const value = offset + position.character;
+ const end = text.indexOf("\n", offset);
+ assert.ok(value <= (end < 0 ? text.length : end), "Edit character is inside its captured line");
+ return value;
+}
+function markerRange(text, marker, spelling = "#old") {
+ const comment = "/*" + marker + "*/";
+ const index = text.indexOf(comment);
+ assert.notEqual(index, -1, "Missing marker " + marker);
+ const offset = index + comment.length;
+ assert.equal(text.slice(offset, offset + spelling.length), spelling);
+ return { start: positionAt(text, offset), end: positionAt(text, offset + spelling.length) };
+}
+const rangeKey = (range) => JSON.stringify(range);
+function assertVersionedEdit(edit, text, version, markers, replacement = "#new") {
+ assert.ok(edit, "Expected private rename edit");
+ assert.ok(edit.changes === undefined || edit.changes === null, "Private rename never sends unversioned changes");
+ assert.equal(edit.documentChanges?.length, 1, "Exactly one captured document is edited");
+ const change = edit.documentChanges[0];
+ assert.ok(sameUri(change.textDocument?.uri, uri()));
+ assert.equal(change.textDocument.version, version, "Captured open-buffer version accompanies every private edit");
+ const edits = change.edits;
+ assert.equal(edits.length, markers.length, "Complete selected lexical domain, without unrelated private names");
+ const expected = markers.map((marker) => rangeKey(markerRange(text, marker))).sort();
+ assert.deepEqual(edits.map((value) => rangeKey(value.range)).sort(), expected, "Exact whole #token UTF-16 ranges");
+ assert.equal(new Set(edits.map((value) => rangeKey(value.range))).size, edits.length, "No duplicate edits");
+ const ordered = edits.map((value) => ({ ...value, start: offsetAt(text, value.range.start), end: offsetAt(text, value.range.end) }))
+ .sort((left, right) => left.start - right.start);
+ let end = -1;
+ for (const value of ordered) {
+ assert.ok(value.start >= end, "Private rename edits cannot overlap");
+ assert.equal(text.slice(value.start, value.end), "#old");
+ assert.equal(value.newText, replacement, "Both accepted spellings emit exactly one #");
+ end = value.end;
+ }
+ return ordered;
+}
+function apply(text, edits) {
+ for (const value of [...edits].reverse()) text = text.slice(0, value.start) + value.newText + text.slice(value.end);
+ return text;
+}
+async function request(client, method, params) {
+ const response = await client.request(method, params);
+ assert.equal(response.error, undefined, method + ": " + JSON.stringify(response.error));
+ return response.result;
+}
+async function diagnostics(client, start, version) {
+ const deadline = performance.now() + 20_000;
+ while (performance.now() < deadline) {
+ const found = client.notifications.slice(start).find((item) => item.method === "textDocument/publishDiagnostics" &&
+ sameUri(item.params.uri, uri()) && item.params.version === version);
+ if (found) return found.params.diagnostics;
+ await delay(25);
+ }
+ throw new Error("No diagnostics barrier for document version " + version);
+}
+
+const report = { workspace, server, clients: [] };
+for (const configuration of [
+ { mode: "full", documentChanges: true, lineEnding: "LF" },
+ { mode: "full", documentChanges: true, lineEnding: "CRLF" },
+ { mode: "full", documentChanges: false, lineEnding: "LF" },
+ { mode: "full", documentChanges: undefined, lineEnding: "LF" },
+ { mode: "interop-only", documentChanges: true, lineEnding: "LF" },
+ { mode: "interop-only", documentChanges: false, lineEnding: "LF" },
+ { mode: "interop-only", documentChanges: undefined, lineEnding: "LF" },
+]) {
+ const { mode, documentChanges, lineEnding } = configuration;
+ const client = new LspStdioClient("dotnet", [server, "--language-features", mode, "--diagnostics",
+ mode === "full" ? "all" : "sharpts-only"], { cwd: workspace });
+ let failure;
+ let phase = "initialize";
+ let currentText = "";
+ let version = 0;
+ const cases = [];
+ const runCase = async (name, action) => {
+ phase = name;
+ try { const value = await action(); cases.push(name); return value; }
+ catch (error) { throw new Error(`${mode} documentChanges=${documentChanges} ${lineEnding}: ${name} ` +
+ `(version=${version}, suffix=${JSON.stringify(currentText.slice(-160))}): ${error.message}`, { cause: error }); }
+ };
+ try {
+ const initialized = await request(client, "initialize", {
+ processId: process.pid, rootUri: pathToFileURL(workspace).href,
+ workspaceFolders: [{ uri: pathToFileURL(workspace).href, name: "private-rename" }],
+ capabilities: {
+ workspace: { didChangeWatchedFiles: { dynamicRegistration: true },
+ ...(documentChanges === undefined ? {} : { workspaceEdit: { documentChanges } }) },
+ textDocument: { rename: { prepareSupport: true }, publishDiagnostics: { versionSupport: true } },
+ },
+ });
+ assert.equal(Boolean(initialized.capabilities.renameProvider), mode === "full");
+ client.notify("initialized", {});
+ async function setSource(text) {
+ currentText = lineEnding === "CRLF" ? text.replaceAll("\r\n", "\n").replaceAll("\n", "\r\n") : text;
+ const start = client.notifications.length;
+ version++;
+ if (version === 1) client.notify("textDocument/didOpen", {
+ textDocument: { uri: uri(), languageId: "typescript", version, text: currentText },
+ });
+ else client.notify("textDocument/didChange", {
+ textDocument: { uri: uri(), version }, contentChanges: [{ text: currentText }],
+ });
+ return diagnostics(client, start, version);
+ }
+ const at = (marker = "read", spelling = "#old") => {
+ const range = markerRange(currentText, marker, spelling);
+ return { textDocument: { uri: uri() }, position: { ...range.start, character: range.start.character + 1 } };
+ };
+ const prepare = (marker, spelling) => request(client, "textDocument/prepareRename", at(marker, spelling));
+ const rename = (newName, marker, spelling) => request(client, "textDocument/rename", { ...at(marker, spelling), newName });
+ async function verifyLexicalRename() {
+ assert.deepEqual(await setSource("const /*declaration*/lexical = 1;\n/*read*/lexical;\n"), []);
+ assert.ok(await prepare("read", "lexical"));
+ const result = await rename("renamedLexical", "read", "lexical");
+ assert.ok(result?.changes, "Existing lexical changes remain available");
+ assert.ok(result.documentChanges === undefined || result.documentChanges === null,
+ "Negotiating private versioned edits does not replace the existing lexical edit contract");
+ const entries = Object.entries(result.changes);
+ assert.equal(entries.length, 1);
+ assert.ok(sameUri(entries[0][0], uri()));
+ const edits = entries[0][1];
+ assert.equal(edits.length, 2);
+ assert.deepEqual(edits.map((edit) => rangeKey(edit.range)).sort(),
+ ["declaration", "read"].map((marker) => rangeKey(markerRange(currentText, marker, "lexical"))).sort());
+ for (const edit of edits) assert.equal(edit.newText, "renamedLexical");
+ const ordered = edits.map((edit) => ({ ...edit, start: offsetAt(currentText, edit.range.start),
+ end: offsetAt(currentText, edit.range.end) })).sort((left, right) => left.start - right.start);
+ assert.deepEqual(await setSource(apply(currentText, ordered)), [], "Applied lexical edit checks cleanly");
+ }
+ await setSource(base);
+ if (mode === "interop-only") {
+ await runCase("rename-methods-are-unregistered", async () => {
+ for (const method of ["textDocument/prepareRename", "textDocument/rename"]) {
+ const result = await client.request(method, { ...at(), newName: "new" });
+ assert.equal(result.error?.code, -32601);
+ }
+ });
+ report.clients.push({ ...configuration, cases, methodsAbsent: true });
+ continue;
+ }
+ if (documentChanges !== true) {
+ await runCase("private-feature-refuses-unversioned-client", async () => {
+ assert.equal(await prepare(), null);
+ assert.equal(await rename("new"), null);
+ });
+ await runCase("ordinary-lexical-rename-keeps-compatibility-without-versioned-edits", verifyLexicalRename);
+ report.clients.push({ ...configuration, cases, privateRefused: true, lexicalPreserved: true });
+ continue;
+ }
+
+ await runCase("whole-private-token-prepare-and-one-hash-edit-convention", async () => {
+ const prepared = await prepare();
+ assert.deepEqual(prepared.range, markerRange(currentText, "read"));
+ assert.equal(prepared.placeholder, "#old");
+ const plain = await rename("new");
+ const prefixed = await rename("#new");
+ assert.deepEqual(plain, prefixed, "Both accepted spellings have identical deterministic edits");
+ assertVersionedEdit(plain, currentText, version, selectedMarkers);
+ const keyword = await rename("class");
+ assert.deepEqual(keyword, await rename("#class"), "Keyword IdentifierName accepts both spellings");
+ const keywordEdits = assertVersionedEdit(keyword, currentText, version, selectedMarkers, "#class");
+ assert.deepEqual(await setSource(apply(currentText, keywordEdits)), [], "Applied #class keyword edits check cleanly");
+ const keywordPrepare = await prepare("read", "#class");
+ assert.equal(keywordPrepare.placeholder, "#class");
+ assert.deepEqual(keywordPrepare.range, markerRange(currentText, "read", "#class"));
+ await setSource(base);
+ const longer = await rename("longerName");
+ assert.deepEqual(longer, await rename("#longerName"));
+ const edits = assertVersionedEdit(longer, currentText, version, selectedMarkers, "#longerName");
+ const oldOther = currentText.slice(currentText.indexOf("class Other"));
+ const applied = apply(currentText, edits);
+ assert.equal(applied.slice(applied.indexOf("class Other")), oldOther,
+ "Unrelated owner, same-spelled private names, comments and string text remain unchanged");
+ assert.deepEqual(await setSource(applied), [], "Applied edit reparses and checks through fresh diagnostics");
+ const rebound = await prepare("read", "#longerName");
+ assert.equal(rebound.placeholder, "#longerName");
+ assert.deepEqual(rebound.range, markerRange(currentText, "read", "#longerName"));
+ });
+
+ for (const [name, source, markers] of [
+ ["instance-method", "class Box { /*declaration*/#old(value: number): number { return value; } " +
+ "run(): number { return this./*read*/#old(1); } } new Box().run();", ["declaration", "read"]],
+ ["static-field", "class Box { static /*declaration*/#old: number = 1; " +
+ "static run(): number { Box./*write*/#old = 2; return Box./*read*/#old; } } Box.run();", ["declaration", "write", "read"]],
+ ["static-method", "class Box { static /*declaration*/#old(value: number): number { return value; } " +
+ "static run(): number { return Box./*read*/#old(1); } } Box.run();", ["declaration", "read"]],
+ ["class-expression", "const Model = class { /*declaration*/#old: number = 1; " +
+ "run(): number { return this./*read*/#old; } }; new Model().run();", ["declaration", "read"]],
+ ["class-inside-ordinary-function", "function make(): number { class Local { /*declaration*/#old: number = 1; " +
+ "run(): number { return this./*read*/#old; } } return new Local().run(); } make();", ["declaration", "read"]],
+ ]) {
+ await runCase(name, async () => {
+ assert.deepEqual(await setSource(source), []);
+ assert.equal((await prepare()).placeholder, "#old");
+ const edits = assertVersionedEdit(await rename("new"), currentText, version, markers);
+ assert.deepEqual(await setSource(apply(currentText, edits)), [], "Applied supported-domain edit checks cleanly");
+ });
+ }
+
+ await runCase("invalid-reserved-and-cross-facet-collision-names", async () => {
+ await setSource(base);
+ for (const invalid of ["", "#", "##new", "1bad", "bad-name", "bad name", "constructor", "#constructor", "taken", "#taken"])
+ assert.equal(await rename(invalid), null, "Refused replacement " + JSON.stringify(invalid));
+ });
+ for (const [name, source, spelling] of [
+ ["selected-owner-in-another-private-environment", "class Outer { make(): any { class Inner { /*read*/#old: number = 1; } return new Inner(); } }", "#old"],
+ ["owner-contains-nested-class", "class Box { #old: number = 1; run(): number { class Inner {} return this./*read*/#old; } }", "#old"],
+ ["unresolved-private-occurrence", "class Box { #old: number = 1; run(loose: any): number { loose.#old; return this./*read*/#old; } }", "#old"],
+ ["recovered-domain", "class Box { #old: number = 1; run(): number { return this./*read*/#old; }", "#old"],
+ ["unrelated-target-document-parse-error", "class Box { #old: number = 1; run(): number { return this./*read*/#old; } } const = 1;", "#old"],
+ ["private-compound-original-is-parser-rejected", "class Box { #old: number = 1; run(): number { this.#old += 1; return this./*read*/#old; } }", "#old"],
+ ["private-logical-original-is-parser-rejected", "class Box { #old: number = 1; run(): number { this.#old ||= 1; return this./*read*/#old; } }", "#old"],
+ ["private-update-original-is-parser-rejected", "class Box { #old: number = 1; run(): number { this.#old++; return this./*read*/#old; } }", "#old"],
+ ["public-member-of-branded-class", "class Box { #brand: number = 1; old: number = 1; run(): number { return this./*read*/old; } }", "old"],
+ ["typescript-private", "class Box { private old: number = 1; run(): number { return this./*read*/old; } }", "old"],
+ ["protected-member", "class Box { protected old: number = 1; run(): number { return this./*read*/old; } }", "old"],
+ ["constructor-parameter-property", "class Box { constructor(public old: number) {} run(): number { return this./*read*/old; } }", "old"],
+ ]) {
+ await runCase(name, async () => {
+ await setSource(source);
+ assert.equal(await prepare("read", spelling), null);
+ assert.equal(await rename("new", "read", spelling), null);
+ });
+ }
+
+ await runCase("unrelated-broken-workspace-does-not-block-a-local-private-domain", async () => {
+ await setSource(base);
+ await mkdir(join(workspace, "broken"), { recursive: true });
+ const broken = join(workspace, "broken/tsconfig.json");
+ await writeFile(broken, "{ invalid unrelated config");
+ const changedStart = client.notifications.length;
+ client.notify("workspace/didChangeWatchedFiles", { changes: [{ uri: uri("broken/tsconfig.json"), type: 1 }] });
+ await diagnostics(client, changedStart, version);
+ assert.equal((await prepare()).placeholder, "#old");
+ assertVersionedEdit(await rename("new"), currentText, version, selectedMarkers);
+ await unlink(broken);
+ const deletedStart = client.notifications.length;
+ client.notify("workspace/didChangeWatchedFiles", { changes: [{ uri: uri("broken/tsconfig.json"), type: 3 }] });
+ await diagnostics(client, deletedStart, version);
+ });
+ let cancellationOutcome;
+ await runCase("actual-cancellation-preserves-peer-and-fresh-versioned-edit", async () => {
+ const invalidatedStart = client.notifications.length;
+ client.notify("workspace/didChangeWatchedFiles", { changes: [{ uri: uri(), type: 2 }] });
+ await diagnostics(client, invalidatedStart, version);
+ const cancelled = client.beginRequest("textDocument/rename", { ...at(), newName: "new" });
+ const peer = rename("new");
+ void peer.catch(() => {});
+ assert.ok(cancelled.cancel(), "Actual $/cancelRequest notification was sent");
+ const response = await cancelled.response;
+ if (response.error) {
+ assert.equal(response.error.code, -32800);
+ cancellationOutcome = "request-cancelled";
+ } else if (response.result === null) cancellationOutcome = "no-result-during-invalidation";
+ else {
+ assertVersionedEdit(response.result, currentText, version, selectedMarkers);
+ cancellationOutcome = "completed-before-cancel";
+ }
+ assertVersionedEdit(await peer, currentText, version, selectedMarkers);
+ assertVersionedEdit(await rename("new"), currentText, version, selectedMarkers);
+ });
+ await runCase("versioned-client-and-private-service-preserve-ordinary-lexical-rename", verifyLexicalRename);
+ report.clients.push({ ...configuration, cases, versionedEditsOnly: true, wholePrivateToken: true,
+ appliedFreshDiagnostics: true, unrelatedSameNamePreserved: true, brokenWorkspaceIndependent: true,
+ cancellationOutcome, peerAndFreshSurvived: true });
+ } catch (error) {
+ failure = error;
+ await writeFile(join(workspace, "failure.json"), JSON.stringify({ configuration, phase, version, currentText,
+ message: error.message, notifications: client.notifications.slice(-12), report }, null, 2) + "\n").catch(() => {});
+ throw error;
+ } finally {
+ try { await client.close(); }
+ catch (error) {
+ if (!failure) throw error;
+ console.error("Cleanup after test failure: " + error.message);
+ }
+ }
+}
+await writeFile(join(workspace, "result.json"), JSON.stringify(report, null, 2) + "\n");
+console.log(JSON.stringify(report, null, 2));
diff --git a/scripts/test-semantic-completion.mjs b/scripts/test-semantic-completion.mjs
new file mode 100644
index 000000000..0eb86ce85
--- /dev/null
+++ b/scripts/test-semantic-completion.mjs
@@ -0,0 +1,206 @@
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
+import { dirname, resolve, join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { setTimeout as delay } from "node:timers/promises";
+import { LspStdioClient } from "./lib/lsp-stdio.mjs";
+
+const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const server = resolve(repository, process.argv[2] ?? "src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll");
+await mkdir(join(repository, "artifacts/semantic-completion-smoke"), { recursive: true });
+const workspace = await mkdtemp(join(repository, "artifacts/semantic-completion-smoke/run-"));
+const dependency = "export class Base { field: number = 1; read(): number { return this.field; } }\n" +
+ "export class Derived extends Base { own: string = 'own'; }\n";
+const dirtyDependency = dependency.replaceAll("field", "changedField");
+const header = "import { Derived } from './dep';\r\nconst item = new Derived();\r\n";
+await writeFile(join(workspace, "tsconfig.json"), JSON.stringify({
+ compilerOptions: { noLib: true, types: [] }, include: ["*.ts"],
+}));
+await writeFile(join(workspace, "dep.ts"), dependency);
+await writeFile(join(workspace, "main.ts"), header);
+const uri = (name) => pathToFileURL(join(workspace, name)).href;
+const sameUri = (left, right) => process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
+const positionAt = (text, offset) => {
+ const prefix = text.slice(0, offset);
+ const last = prefix.lastIndexOf("\n");
+ return { line: prefix.split("\n").length - 1, character: offset - last - 1 };
+};
+const offsetAt = (text, position) => {
+ let offset = 0;
+ for (let line = 0; line < position.line; line++) {
+ const end = text.indexOf("\n", offset);
+ assert.notEqual(end, -1);
+ offset = end + 1;
+ }
+ return offset + position.character;
+};
+const itemsOf = (result) => Array.isArray(result) ? result : result?.items ?? [];
+async function request(client, method, params) {
+ const response = await client.request(method, params);
+ assert.equal(response.error, undefined, method + ": " + JSON.stringify(response.error));
+ return response.result;
+}
+async function diagnostics(client, start, name, version) {
+ const deadline = performance.now() + 20_000;
+ while (performance.now() < deadline) {
+ const found = client.notifications.slice(start).find((item) => item.method === "textDocument/publishDiagnostics" &&
+ sameUri(item.params.uri, uri(name)) && item.params.version === version);
+ if (found) return found.params.diagnostics;
+ await delay(25);
+ }
+ throw new Error("No diagnostics barrier for " + name + " version " + version);
+}
+function assertItems(items, supportedKinds) {
+ assert.ok(items.length <= 256, "Semantic item bound");
+ const labels = items.map((item) => item.label);
+ assert.deepEqual(labels, [...new Set(labels)].sort(), "Deterministic unique labels");
+ for (const item of items) {
+ assert.ok(item.insertTextFormat === undefined || item.insertTextFormat === 1, "Plain text completion");
+ assert.ok(item.detail === undefined || item.detail.length <= 4096, "Bounded detail");
+ assert.ok(item.textEdit?.range, "Exact simple text edit");
+ if (supportedKinds) assert.ok(item.kind === undefined || supportedKinds.includes(item.kind), "Negotiated item kind");
+ }
+}
+function selected(items, name) {
+ const item = items.find((candidate) => candidate.label === name);
+ assert.ok(item, "Missing completion " + name + ": " + items.map((candidate) => candidate.label).join(", "));
+ return item;
+}
+function apply(text, item, start, end) {
+ assert.deepEqual(item.textEdit.range, { start: positionAt(text, start), end: positionAt(text, end) });
+ const actualStart = offsetAt(text, item.textEdit.range.start);
+ const actualEnd = offsetAt(text, item.textEdit.range.end);
+ return text.slice(0, actualStart) + item.textEdit.newText + text.slice(actualEnd);
+}
+
+const report = { workspace, server, clients: [] };
+for (const configuration of [
+ { mode: "full", restrictedKinds: false },
+ { mode: "full", restrictedKinds: true },
+ { mode: "full", restrictedKinds: true, supportedKinds: [2] },
+ { mode: "interop-only", restrictedKinds: true },
+]) {
+ const { mode, restrictedKinds } = configuration;
+ const supportedKinds = restrictedKinds ? configuration.supportedKinds ?? [1] : undefined;
+ const client = new LspStdioClient("dotnet", [server, "--language-features", mode, "--diagnostics",
+ mode === "full" ? "all" : "sharpts-only"], { cwd: workspace });
+ let version = 0;
+ let currentText = "";
+ let failure;
+ try {
+ const capabilities = await request(client, "initialize", {
+ processId: process.pid, rootUri: pathToFileURL(workspace).href,
+ workspaceFolders: [{ uri: pathToFileURL(workspace).href, name: "semantic-completion" }],
+ capabilities: { textDocument: {
+ completion: { completionItem: { snippetSupport: false },
+ ...(supportedKinds ? { completionItemKind: { valueSet: supportedKinds } } : {}) },
+ publishDiagnostics: { versionSupport: true },
+ } },
+ });
+ const triggers = capabilities.capabilities.completionProvider?.triggerCharacters ?? [];
+ for (const legacy of ["@", "<", " ", "=", "\""]) assert.ok(triggers.includes(legacy), "Legacy trigger " + legacy);
+ assert.equal(triggers.includes("."), mode === "full", "Member trigger isolation");
+ for (const feature of ["documentFormattingProvider", "documentRangeFormattingProvider", "documentOnTypeFormattingProvider"])
+ assert.ok(!capabilities.capabilities[feature]);
+ client.notify("initialized", {});
+ async function setSource(text) {
+ const start = client.notifications.length;
+ currentText = text;
+ version++;
+ if (version === 1) client.notify("textDocument/didOpen", {
+ textDocument: { uri: uri("main.ts"), languageId: "typescript", version, text },
+ });
+ else client.notify("textDocument/didChange", {
+ textDocument: { uri: uri("main.ts"), version }, contentChanges: [{ text }],
+ });
+ return diagnostics(client, start, "main.ts", version);
+ }
+ async function complete(offset = currentText.length) {
+ return itemsOf(await request(client, "textDocument/completion", {
+ textDocument: { uri: uri("main.ts") }, position: positionAt(currentText, offset), context: { triggerKind: 1 },
+ }));
+ }
+ const lexical = header + "const lexicalName: number = 1;\r\n" +
+ "function read() { const lexicalLocal = lexicalName; return /* 😀 */ lexicZZ; }\r\n";
+ await setSource(lexical);
+ const lexicalStart = lexical.indexOf("lexicZZ");
+ let items = await complete(lexicalStart + 5);
+ if (mode === "full") {
+ assertItems(items, supportedKinds);
+ const edited = apply(lexical, selected(items, "lexicalLocal"), lexicalStart, lexicalStart + "lexicZZ".length);
+ assert.deepEqual(await setSource(edited), [], "Applied lexical edit checks cleanly");
+ } else assert.deepEqual(items, []);
+
+ const partial = header + "/* 😀 */ item.fiZZ;\r\n";
+ await setSource(partial);
+ const partialStart = partial.indexOf("fiZZ");
+ items = await complete(partialStart + 2);
+ if (mode === "full") {
+ assertItems(items, supportedKinds);
+ const edited = apply(partial, selected(items, "field"), partialStart, partialStart + 4);
+ assert.deepEqual(await setSource(edited), [], "Applied member edit checks cleanly");
+ } else assert.deepEqual(items, []);
+
+ for (const suffix of ["item.", "item?."]) {
+ await setSource(header + suffix);
+ items = await complete();
+ if (mode === "full") {
+ assertItems(items, supportedKinds);
+ const field = selected(items, "field");
+ selected(items, "read");
+ const concurrent = await Promise.all(Array.from({ length: 4 }, () => complete()));
+ concurrent.forEach((values) => assert.deepEqual(values, items));
+ const edited = apply(currentText, field, currentText.length, currentText.length) + ";";
+ assert.deepEqual(await setSource(edited), [], "Applied recovered member edit checks cleanly");
+ } else assert.deepEqual(items, []);
+ }
+ if (mode === "full") {
+ await setSource("const shape = { total: 1 }; shape.");
+ selected(await complete(), "total");
+ await setSource("import * as Model from './dep'; Model.");
+ selected(await complete(), "Derived");
+ await setSource(header + "item.");
+ const dirtyStart = client.notifications.length;
+ client.notify("textDocument/didOpen", {
+ textDocument: { uri: uri("dep.ts"), languageId: "typescript", version: 1, text: dirtyDependency },
+ });
+ await diagnostics(client, dirtyStart, "dep.ts", 1);
+ const changed = await complete();
+ selected(changed, "changedField");
+ assert.ok(!changed.some((item) => item.label === "field"));
+ const closeStart = client.notifications.length;
+ client.notify("textDocument/didClose", { textDocument: { uri: uri("dep.ts") } });
+ await diagnostics(client, closeStart, "main.ts", version);
+ selected(await complete(), "field");
+ }
+ for (const refused of [
+ "const item: any = {}; item.",
+ "missing.",
+ "const value = 1.",
+ "const lexicalName = 1; // lexical",
+ "const lexicalName = 1; const text = 'lexical';",
+ ]) {
+ await setSource(refused);
+ const offset = refused.includes("'lexical'") ? refused.indexOf("'lexical'") + 4 : refused.length;
+ assert.deepEqual(await complete(offset), [], "Refused unreliable context: " + refused);
+ }
+ await setSource("@Do");
+ selected(await complete(), "DotNetType");
+ report.clients.push({ mode, supportedKinds, ordinaryCompletion: mode === "full", decoratorCompletion: true,
+ memberTriggers: mode === "full", recoveredDotAndOptional: mode === "full",
+ exactAppliedUtf16CrLfEdits: mode === "full", dirtyDependencyAndClose: mode === "full" });
+ } catch (error) {
+ failure = error;
+ error.message = `${mode} version ${version} (${currentText.slice(-100)}): ${error.message}; ` +
+ `notifications: ${JSON.stringify(client.notifications.slice(-10))}`;
+ throw error;
+ } finally {
+ try { await client.close(); }
+ catch (error) {
+ if (!failure) throw error;
+ console.error("Cleanup after test failure: " + error.message);
+ }
+ }
+}
+await writeFile(join(workspace, "result.json"), JSON.stringify(report, null, 2) + "\n");
+console.log(JSON.stringify(report, null, 2));
diff --git a/scripts/test-semantic-hover.mjs b/scripts/test-semantic-hover.mjs
new file mode 100644
index 000000000..d005330ab
--- /dev/null
+++ b/scripts/test-semantic-hover.mjs
@@ -0,0 +1,124 @@
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
+import { dirname, resolve, join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { setTimeout as delay } from "node:timers/promises";
+import { LspStdioClient } from "./lib/lsp-stdio.mjs";
+
+const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const server = resolve(repository, process.argv[2] ?? "src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll");
+await mkdir(join(repository, "artifacts/semantic-hover-smoke"), { recursive: true });
+const workspace = await mkdtemp(join(repository, "artifacts/semantic-hover-smoke/run-"));
+const dependency = "export const value: number = 123;\n";
+const dirtyDependency = "export const value: string = 'dirty';\n";
+const source = "import { value as renamed } from './dep';\r\n" +
+ "import { StringBuilder as SB } from 'dotnet:System.Text.StringBuilder';\r\n" +
+ "const local = /*alias*/renamed;\r\n/* 😀 */ /*local*/local;\r\n" +
+ "const builder = new SB();\r\nbuilder./*clr*/append('x');\r\n";
+const decorator = '@DotNetType("System.String")\ndeclare class NetString {}\n';
+await writeFile(join(workspace, "tsconfig.json"), JSON.stringify({
+ compilerOptions: { noLib: true, types: [], experimentalDecorators: true }, include: ["*.ts"],
+}));
+await writeFile(join(workspace, "main.ts"), source);
+await writeFile(join(workspace, "dep.ts"), dependency);
+const uri = (name) => pathToFileURL(join(workspace, name)).href;
+const sameUri = (left, right) => process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
+const positionAt = (text, offset) => {
+ const prefix = text.slice(0, offset);
+ const lastLine = prefix.lastIndexOf("\n");
+ return { line: prefix.split("\n").length - 1, character: offset - lastLine - 1 };
+};
+const markerRange = (marker) => {
+ const comment = `/*${marker}*/`;
+ const index = source.indexOf(comment);
+ assert.notEqual(index, -1, `Missing marker ${marker}`);
+ const start = index + comment.length;
+ const name = source.slice(start).match(/^[A-Za-z_][A-Za-z_0-9]*/)?.[0];
+ assert.ok(name);
+ return { start: positionAt(source, start), end: positionAt(source, start + name.length) };
+};
+const hoverPosition = (marker) => {
+ const range = markerRange(marker);
+ return { textDocument: { uri: uri("main.ts") }, position: { ...range.start, character: range.start.character + 1 } };
+};
+async function request(client, method, params) {
+ const response = await client.request(method, params);
+ assert.equal(response.error, undefined, `${method}: ${JSON.stringify(response.error)}`);
+ return response.result;
+}
+async function awaitDiagnostics(client, start, document, version) {
+ const deadline = performance.now() + 20_000;
+ while (performance.now() < deadline) {
+ const match = client.notifications.slice(start).find((item) => item.method === "textDocument/publishDiagnostics" &&
+ sameUri(item.params.uri, uri(document)) && item.params.version === version);
+ if (match) {
+ assert.deepEqual(match.params.diagnostics, [], `Unexpected ${document} diagnostics`);
+ return;
+ }
+ await delay(25);
+ }
+ throw new Error(`No version ${version} diagnostics for ${document}: ${JSON.stringify(client.notifications.slice(start))}`);
+}
+function assertSemanticHover(hover, marker, format, type) {
+ assert.ok(hover, `${marker} semantic hover was absent`);
+ assert.equal(hover.contents.kind, format);
+ assert.match(hover.contents.value, new RegExp(`\\b${type}\\b`));
+ assert.deepEqual(hover.range, markerRange(marker));
+ if (format === "markdown") assert.match(hover.contents.value, /```typescript/);
+ else assert.ok(!hover.contents.value.includes("```"));
+}
+
+const report = { workspace, server, clients: [] };
+for (const mode of ["full", "interop-only"]) {
+ for (const format of ["markdown", "plaintext"]) {
+ const client = new LspStdioClient("dotnet", [server, "--language-features", mode, "--diagnostics", mode === "full" ? "all" : "sharpts-only"], { cwd: workspace });
+ try {
+ const initialized = await request(client, "initialize", {
+ processId: process.pid, rootUri: pathToFileURL(workspace).href,
+ workspaceFolders: [{ uri: pathToFileURL(workspace).href, name: "semantic-hover" }],
+ capabilities: { textDocument: { hover: { contentFormat: [format] }, publishDiagnostics: { versionSupport: true } } },
+ });
+ assert.ok(initialized.capabilities.hoverProvider);
+ client.notify("initialized", {});
+ client.notify("textDocument/didOpen", { textDocument: { uri: uri("main.ts"), languageId: "typescript", version: 1, text: source } });
+ if (mode === "full") await awaitDiagnostics(client, 0, "main.ts", 1);
+
+ const local = await request(client, "textDocument/hover", hoverPosition("local"));
+ const alias = await request(client, "textDocument/hover", hoverPosition("alias"));
+ if (mode === "full") {
+ assertSemanticHover(local, "local", format, "number");
+ assertSemanticHover(alias, "alias", format, "number");
+ const concurrent = await Promise.all(Array.from({ length: 4 }, () => request(client, "textDocument/hover", hoverPosition("local"))));
+ concurrent.forEach((hover) => assertSemanticHover(hover, "local", format, "number"));
+ } else {
+ assert.equal(local, null);
+ assert.equal(alias, null);
+ }
+ const clr = await request(client, "textDocument/hover", hoverPosition("clr"));
+ assert.match(JSON.stringify(clr), /Append/);
+ assert.equal(clr.contents.kind, format);
+ client.notify("textDocument/didOpen", { textDocument: { uri: uri("decorator.ts"), languageId: "typescript", version: 1, text: decorator } });
+ const clrDecorator = await request(client, "textDocument/hover", {
+ textDocument: { uri: uri("decorator.ts") }, position: { line: 0, character: 4 },
+ });
+ assert.match(JSON.stringify(clrDecorator), /System\.String/);
+ assert.equal(clrDecorator.contents.kind, format);
+
+ if (mode === "full") {
+ const openStart = client.notifications.length;
+ client.notify("textDocument/didOpen", { textDocument: { uri: uri("dep.ts"), languageId: "typescript", version: 1, text: dirtyDependency } });
+ await awaitDiagnostics(client, openStart, "dep.ts", 1);
+ assertSemanticHover(await request(client, "textDocument/hover", hoverPosition("alias")), "alias", format, "string");
+ assertSemanticHover(await request(client, "textDocument/hover", hoverPosition("local")), "local", format, "string");
+ const closeStart = client.notifications.length;
+ client.notify("textDocument/didClose", { textDocument: { uri: uri("dep.ts") } });
+ await awaitDiagnostics(client, closeStart, "main.ts", 1);
+ assertSemanticHover(await request(client, "textDocument/hover", hoverPosition("local")), "local", format, "number");
+ }
+ report.clients.push({ mode, format, ordinaryHover: mode === "full", clrUsage: true, clrDecorator: true,
+ exactUtf16CrLfRanges: mode === "full", dirtyAliasAndClose: mode === "full" });
+ } finally { await client.close(); }
+ }
+}
+await writeFile(join(workspace, "result.json"), JSON.stringify(report, null, 2) + "\n");
+console.log(JSON.stringify(report, null, 2));
diff --git a/scripts/test-semantic-signatures.mjs b/scripts/test-semantic-signatures.mjs
new file mode 100644
index 000000000..546983d76
--- /dev/null
+++ b/scripts/test-semantic-signatures.mjs
@@ -0,0 +1,350 @@
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
+import { dirname, resolve, join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { setTimeout as delay } from "node:timers/promises";
+import { LspStdioClient } from "./lib/lsp-stdio.mjs";
+
+const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const server = resolve(repository, process.argv[2] ?? "src/SharpTS.LanguageServer/bin/Release/net10.0/SharpTS.LanguageServer.dll");
+await mkdir(join(repository, "artifacts/semantic-signature-smoke"), { recursive: true });
+const workspace = await mkdtemp(join(repository, "artifacts/semantic-signature-smoke/run-"));
+const dependency = "export function fromDependency(diskValue: number): number { return diskValue; }\n";
+const dirtyDependency = "export function fromDependency(dirtyValue: string): string { return dirtyValue; }\n";
+const header = "import { fromDependency as imported } from './dep';\r\n" +
+ "function pair(first: number, second: string): number { return first; }\r\n" +
+ "function choose(value: number): boolean;\r\n" +
+ "function choose(value: string): boolean;\r\n" +
+ "function choose(value: any): boolean { return true; }\r\n" +
+ "function identity(value: T): T { return value; }\r\n" +
+ "function many(head: number, ...tail: string[]): number { return head; }\r\n" +
+ "declare function utf16(emoji: '😀', count: number): number;\r\n" +
+ "declare class Build { constructor(value: number); constructor(value: string); }\r\n";
+await writeFile(join(workspace, "tsconfig.json"), JSON.stringify({
+ compilerOptions: { noLib: true, types: [], experimentalDecorators: true }, include: ["*.ts"],
+}));
+await writeFile(join(workspace, "dep.ts"), dependency);
+await writeFile(join(workspace, "main.ts"), header);
+const uri = (name) => pathToFileURL(join(workspace, name)).href;
+const sameUri = (left, right) => process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
+const positionAt = (text, offset) => {
+ const prefix = text.slice(0, offset);
+ const last = prefix.lastIndexOf("\n");
+ return { line: prefix.split("\n").length - 1, character: offset - last - 1 };
+};
+const caretMarker = "/*caret*/";
+function marked(text) {
+ const offset = text.indexOf(caretMarker);
+ assert.notEqual(offset, -1, "Missing caret marker");
+ assert.equal(text.indexOf(caretMarker, offset + 1), -1, "Duplicate caret marker");
+ return { text: text.slice(0, offset) + text.slice(offset + caretMarker.length), offset };
+}
+async function request(client, method, params) {
+ const response = await client.request(method, params);
+ assert.equal(response.error, undefined, method + ": " + JSON.stringify(response.error));
+ return response.result;
+}
+async function diagnostics(client, start, name, version) {
+ const deadline = performance.now() + 20_000;
+ while (performance.now() < deadline) {
+ const found = client.notifications.slice(start).find((item) => item.method === "textDocument/publishDiagnostics" &&
+ sameUri(item.params.uri, uri(name)) && item.params.version === version);
+ if (found) return found.params.diagnostics;
+ await delay(25);
+ }
+ throw new Error("No diagnostics barrier for " + name + " version " + version);
+}
+function parameterText(signature, index, labelOffsets) {
+ const parameter = signature.parameters?.[index];
+ assert.ok(parameter, "Missing parameter " + index + ": " + JSON.stringify(signature));
+ if (!labelOffsets) {
+ assert.equal(typeof parameter.label, "string", "String-label capability fallback");
+ return parameter.label;
+ }
+ assert.ok(Array.isArray(parameter.label), "Negotiated label offsets");
+ assert.equal(parameter.label.length, 2);
+ const [start, end] = parameter.label;
+ assert.ok(Number.isInteger(start) && Number.isInteger(end) && start >= 0 && end > start && end <= signature.label.length,
+ "UTF-16 label offsets stay inside the exact signature label");
+ assert.ok(!/[\uDC00-\uDFFF]/.test(signature.label[start]), "Parameter start does not split a surrogate pair");
+ assert.ok(!/[\uD800-\uDBFF]/.test(signature.label[end - 1]), "Parameter end does not split a surrogate pair");
+ return signature.label.slice(start, end);
+}
+function assertHelp(help, negotiated) {
+ assert.ok(help, "Expected signature help");
+ assert.ok(help.signatures?.length > 0 && help.signatures.length <= 32, "Bounded public candidate list");
+ for (const signature of help.signatures) {
+ assert.equal(typeof signature.label, "string");
+ assert.ok(signature.label.length > 0 && signature.label.length <= 4096, "Bounded signature label");
+ for (let index = 0; index < (signature.parameters?.length ?? 0); index++)
+ assert.match(parameterText(signature, index, negotiated), /:/);
+ if (!negotiated) assert.equal(signature.activeParameter, undefined, "Per-signature active parameter is negotiated");
+ }
+ return help;
+}
+function assertNoWinner(help) {
+ // An omitted activeSignature may cause a client to highlight candidate zero by default;
+ // the server must not publish that presentation default as a checker-selected overload.
+ assert.ok(help.activeSignature === undefined || help.activeSignature === null,
+ "Candidates-only/recovered call has no selected overload");
+}
+function assertActive(help, rawIndex, negotiated, signatureIndex = help.activeSignature ?? 0) {
+ assert.equal(help.activeParameter, rawIndex, "Top-level active parameter");
+ if (negotiated) assert.equal(help.signatures[signatureIndex].activeParameter, rawIndex,
+ "Negotiated per-signature active parameter");
+}
+
+const report = { workspace, server, clients: [] };
+for (const configuration of [
+ { mode: "full", negotiated: true },
+ { mode: "full", negotiated: false },
+ { mode: "interop-only", negotiated: true },
+ { mode: "interop-only", negotiated: false },
+]) {
+ const { mode, negotiated } = configuration;
+ const client = new LspStdioClient("dotnet", [server, "--language-features", mode, "--diagnostics",
+ mode === "full" ? "all" : "sharpts-only"], { cwd: workspace });
+ let version = 0;
+ let currentText = "";
+ let currentOffset = 0;
+ let failure;
+ let cancellationOutcome;
+ let clrCall = "unavailable";
+ const checkedCases = [];
+ try {
+ const initialized = await request(client, "initialize", {
+ processId: process.pid, rootUri: pathToFileURL(workspace).href,
+ workspaceFolders: [{ uri: pathToFileURL(workspace).href, name: "semantic-signatures" }],
+ // Deliberately omit hover and completion capabilities: signature-only clients must initialize.
+ capabilities: { textDocument: {
+ signatureHelp: { contextSupport: true, signatureInformation: {
+ parameterInformation: { labelOffsetSupport: negotiated }, activeParameterSupport: negotiated,
+ } },
+ publishDiagnostics: { versionSupport: true },
+ } },
+ });
+ const provider = initialized.capabilities.signatureHelpProvider;
+ assert.ok(provider, "Signature help remains registered in both modes");
+ for (const trigger of ["(", ","]) assert.ok(provider.triggerCharacters?.includes(trigger), "Legacy trigger " + trigger);
+ client.notify("initialized", {});
+ async function setSource(markedText) {
+ const fixture = marked(markedText);
+ currentText = fixture.text;
+ currentOffset = fixture.offset;
+ const start = client.notifications.length;
+ version++;
+ if (version === 1) client.notify("textDocument/didOpen", {
+ textDocument: { uri: uri("main.ts"), languageId: "typescript", version, text: currentText },
+ });
+ else client.notify("textDocument/didChange", {
+ textDocument: { uri: uri("main.ts"), version }, contentChanges: [{ text: currentText }],
+ });
+ return diagnostics(client, start, "main.ts", version);
+ }
+ function params(context = { triggerKind: 1, isRetrigger: false }) {
+ return { textDocument: { uri: uri("main.ts") }, position: positionAt(currentText, currentOffset), context };
+ }
+ async function help(context) { return request(client, "textDocument/signatureHelp", params(context)); }
+ async function semanticCase(name, fixture, validate, context) {
+ try {
+ await setSource(fixture);
+ const result = await help(context);
+ if (mode === "full") validate(assertHelp(result, negotiated));
+ else assert.equal(result, null, "Interop refuses ordinary signature: " + name);
+ checkedCases.push(name);
+ return result;
+ } catch (error) {
+ throw new Error(`${name} (${mode}, negotiated=${negotiated}, version=${version}, ` +
+ `source=${JSON.stringify(currentText.slice(-140))}): ${error.message}`, { cause: error });
+ }
+ }
+
+ await semanticCase("ordinary-pair", header + "/* 😀 */ pair(/*caret*/1, 'text');\r\n", (result) => {
+ assert.equal(result.signatures.length, 1);
+ assert.match(parameterText(result.signatures[0], 0, negotiated), /^first: number$/);
+ assert.match(parameterText(result.signatures[0], 1, negotiated), /^second: string$/);
+ assert.equal(result.activeSignature, 0);
+ assertActive(result, 0, negotiated);
+ }, { triggerKind: 2, triggerCharacter: "(", isRetrigger: false });
+
+ await semanticCase("actual-overload-winner", header + "choose(/*caret*/'text');", (result) => {
+ assert.equal(result.signatures.length, 2);
+ assert.match(parameterText(result.signatures[0], 0, negotiated), /^value: number$/);
+ assert.match(parameterText(result.signatures[1], 0, negotiated), /^value: string$/);
+ assert.equal(result.activeSignature, 1, "Actual checker ordinal, even with identical return types");
+ assertActive(result, 0, negotiated, 1);
+ assert.ok(!result.signatures.some((signature) => /value: any/.test(signature.label)), "Implementation overload remains hidden");
+ });
+
+ await semanticCase("generic-instantiation", header + "identity(/*caret*/1);", (result) => {
+ assert.match(parameterText(result.signatures[0], 0, negotiated), /^value: number$/);
+ assert.match(result.signatures[0].label, /number/);
+ assert.equal(result.activeSignature, 0);
+ });
+ await semanticCase("generic-type-argument-comma", header +
+ "function combine(left: A, right: B): A { return left; }\r\n" +
+ "combine(1, /*caret*/'x');", (result) => {
+ assert.equal(result.signatures[0].parameters.length, 2);
+ assert.match(parameterText(result.signatures[0], 1, negotiated), /^right: string$/);
+ assertActive(result, 1, negotiated);
+ });
+ await semanticCase("invalid-overload-candidates", header + "choose(/*caret*/true);", (result) => {
+ assert.equal(result.signatures.length, 2);
+ assertNoWinner(result);
+ });
+
+ const nested = header + "pair(\r\n identity(/*caret*/1),\r\n `a,b ${identity('x,y')}`\r\n);";
+ const previous = await semanticCase("nested-innermost", nested, (result) => {
+ assert.equal(result.signatures[0].parameters.length, 1);
+ assert.match(parameterText(result.signatures[0], 0, negotiated), /^value: number$/);
+ assertActive(result, 0, negotiated);
+ });
+ await semanticCase("outer-commas-and-retrigger", header +
+ "pair(\r\n identity(1), /* a, ignored comment */\r\n /*caret*/`a,b ${identity('x,y')}`\r\n);", (result) => {
+ assert.equal(result.signatures[0].parameters.length, 2);
+ assertActive(result, 1, negotiated);
+ }, { triggerKind: 3, isRetrigger: true, ...(previous ? { activeSignatureHelp: previous } : {}) });
+
+ await semanticCase("utf16-label-offsets", header + "utf16('😀', /*caret*/1);", (result) => {
+ const signature = result.signatures[0];
+ assert.equal(parameterText(signature, 0, negotiated), 'emoji: "😀"');
+ assert.equal(parameterText(signature, 1, negotiated), "count: number");
+ if (negotiated) {
+ const expected = signature.label.indexOf("count: number");
+ assert.ok(signature.parameters[1].label[0] === expected, "Offsets count UTF-16 units, including the earlier surrogate pair");
+ }
+ assertActive(result, 1, negotiated);
+ }, { triggerKind: 2, triggerCharacter: ",", isRetrigger: true });
+
+ await semanticCase("rest-parameter", header + "many(1, 'a', /*caret*/'b');", (result) => {
+ assert.equal(result.signatures[0].parameters.length, 2);
+ assert.match(parameterText(result.signatures[0], 1, negotiated), /^\.\.\.tail: Array$/);
+ assertActive(result, 1, negotiated);
+ });
+ await semanticCase("optional-call", header + "pair?.(1, /*caret*/'x');", (result) => assertActive(result, 1, negotiated));
+ await semanticCase("completed-string-argument-interior", header + "pair(1, 'a,/*caret*/b');",
+ (result) => assertActive(result, 1, negotiated));
+ await semanticCase("completed-template-argument-interior", header + "pair(1, `a,/*caret*/b`);",
+ (result) => assertActive(result, 1, negotiated));
+ await semanticCase("ambient-constructor", header + "new Build(/*caret*/'text');", (result) => {
+ assert.equal(result.signatures.length, 2);
+ assert.equal(result.activeSignature, 1);
+ assert.ok(result.signatures.every((signature) => signature.label.startsWith("new ")));
+ });
+ await semanticCase("private-call", "class C { #method(value: number): number { return value; } " +
+ "inspect(): number { return this.#method(/*caret*/1); } }", (result) => {
+ assert.equal(result.signatures.length, 1);
+ assert.match(parameterText(result.signatures[0], 0, negotiated), /^value: number$/);
+ assert.equal(result.activeSignature, 0);
+ });
+
+ for (const [name, suffix, index, count] of [
+ ["unfinished-call", "pair(/*caret*/", 0, 1],
+ ["unfinished-second-argument", "pair(1, /*caret*/", 1, 1],
+ ["recovery-hole", "pair(1, , /*caret*/", 2, 1],
+ ["unfinished-overloads", "choose(/*caret*/", 0, 2],
+ ["unfinished-constructor", "new Build(/*caret*/", 0, 2],
+ ["unfinished-nested", "pair(1, identity(/*caret*/", 0, 1],
+ ]) {
+ await semanticCase(name, header + suffix, (result) => {
+ assert.equal(result.signatures.length, count);
+ assertNoWinner(result);
+ // A non-rest signature has no parameter at an out-of-range raw argument index.
+ if (index < result.signatures[0].parameters.length) assertActive(result, index, negotiated);
+ else assert.ok(result.activeParameter === undefined || result.activeParameter === null);
+ });
+ }
+
+ for (const [name, suffix] of [
+ ["comment-interior", "pair(1, /* comment, /*caret*/ ignored */ 'x');"],
+ ["string-with-faux-call", "const text = 'pair(,/*caret*/';"],
+ ["template-with-faux-call", "const text = `pair(,/*caret*/`;"],
+ ["unfinished-string", "pair(1, 'unfinished/*caret*/"],
+ ["after-call-close", "pair(1, 'x')/*caret*/;"],
+ ["no-new-delimiters", "new Build/*caret*/;"],
+ ["unknown-callee", "missing(/*caret*/);"],
+ // The outer call rejects its missing required argument before checking the inner
+ // invocation. Signature help must not perform a new semantic check to invent it.
+ ["unvisited-inner-after-outer-arity-failure", "pair(identity(/*caret*/"],
+ ]) {
+ await setSource(header + suffix);
+ assert.equal(await help(), null, "Refused context: " + name);
+ checkedCases.push(name);
+ }
+
+ // The built-in decorator path remains independent of ordinary semantic signatures.
+ await setSource('@DotNetType(/*caret*/"System.String")\r\ndeclare class NetString {}\r\n');
+ const decorator = await help({ triggerKind: 2, triggerCharacter: "(", isRetrigger: false });
+ assert.equal(decorator.signatures[0].label, "DotNetType(typeName: string)");
+ assert.equal(decorator.activeSignature, 0);
+ assert.equal(decorator.activeParameter, 0);
+
+ // CLR signatures are included only when captured by the checker; this smoke does not
+ // promise support for every reflected overload or synthesize a signature from reflection.
+ await setSource("import { StringBuilder as SB } from 'dotnet:System.Text.StringBuilder';\r\n" +
+ "const builder = new SB();\r\nbuilder.append(/*caret*/'x');\r\n");
+ const clr = await help();
+ if (mode === "interop-only") assert.equal(clr, null);
+ else if (clr !== null) { assertHelp(clr, negotiated); clrCall = "captured-candidates"; }
+
+ if (mode === "full") {
+ await setSource(header + "imported(/*caret*/1);");
+ const disk = assertHelp(await help(), negotiated);
+ assert.match(parameterText(disk.signatures[0], 0, negotiated), /^diskValue: number$/);
+ const concurrent = await Promise.all(Array.from({ length: 4 }, () => help()));
+ concurrent.forEach((result) => assert.deepEqual(result, disk, "Concurrent unchanged requests reuse equivalent checked facts"));
+ const dirtyStart = client.notifications.length;
+ client.notify("textDocument/didOpen", {
+ textDocument: { uri: uri("dep.ts"), languageId: "typescript", version: 1, text: dirtyDependency },
+ });
+ assert.deepEqual(await diagnostics(client, dirtyStart, "dep.ts", 1), []);
+ const dirty = assertHelp(await help(), negotiated);
+ assert.match(parameterText(dirty.signatures[0], 0, negotiated), /^dirtyValue: string$/);
+ assertNoWinner(dirty); // The unchanged number argument is now invalid; retain candidates only.
+ const closeStart = client.notifications.length;
+ client.notify("textDocument/didClose", { textDocument: { uri: uri("dep.ts") } });
+ await diagnostics(client, closeStart, "main.ts", version);
+ const restored = assertHelp(await help(), negotiated);
+ assert.match(parameterText(restored.signatures[0], 0, negotiated), /^diskValue: number$/);
+ assert.equal(restored.activeSignature, 0);
+
+ // Wire cancellation has an unavoidable completion race. A peer and a fresh request
+ // must survive; deterministic service cancellation assertions live in unit tests.
+ await setSource(header + "pair(1, /*caret*/");
+ const cancelled = client.beginRequest("textDocument/signatureHelp", params());
+ const peer = help();
+ // Keep cleanup failures from turning a useful primary assertion into an unhandled rejection.
+ void peer.catch(() => {});
+ const cancelSent = cancelled.cancel();
+ const response = await cancelled.response;
+ if (response.error) {
+ assert.equal(response.error.code, -32800);
+ cancellationOutcome = "request-cancelled";
+ } else if (response.result === null) cancellationOutcome = "no-result-during-cancellation";
+ else {
+ assertNoWinner(assertHelp(response.result, negotiated));
+ cancellationOutcome = "completed-before-cancel";
+ }
+ assert.ok(cancelSent, "Actual $/cancelRequest notification was sent");
+ assertActive(assertHelp(await peer, negotiated), 1, negotiated);
+ assertActive(assertHelp(await help(), negotiated), 1, negotiated);
+ }
+ report.clients.push({ mode, labelOffsetSupport: negotiated, activeParameterSupport: negotiated,
+ initializedWithoutHoverOrCompletion: true, checkedCases, decoratorSignature: true,
+ ordinarySignatures: mode === "full", dirtyDependencyAndClose: mode === "full",
+ cancellationOutcome, freshAfterCancellation: mode === "full", clrCall });
+ } catch (error) {
+ failure = error;
+ error.message = `${mode} negotiated=${negotiated} version ${version} (${currentText.slice(-140)}): ${error.message}; ` +
+ `notifications: ${JSON.stringify(client.notifications.slice(-10))}`;
+ throw error;
+ } finally {
+ try { await client.close(); }
+ catch (error) {
+ if (!failure) throw error;
+ console.error("Cleanup after test failure: " + error.message);
+ }
+ }
+}
+await writeFile(join(workspace, "result.json"), JSON.stringify(report, null, 2) + "\n");
+console.log(JSON.stringify(report, null, 2));
diff --git a/src/SharpTS.LanguageServer/Documentation/XmlDocLoader.cs b/src/SharpTS.LanguageServer/Documentation/XmlDocLoader.cs
index b48615041..295f7888e 100644
--- a/src/SharpTS.LanguageServer/Documentation/XmlDocLoader.cs
+++ b/src/SharpTS.LanguageServer/Documentation/XmlDocLoader.cs
@@ -1,5 +1,7 @@
using System.Reflection;
using System.Xml.Linq;
+using SharpTS.IO;
+using SharpTS.Compilation;
namespace SharpTS.LanguageServer.Documentation;
@@ -8,7 +10,9 @@ namespace SharpTS.LanguageServer.Documentation;
///
public sealed class XmlDocLoader
{
- private readonly Dictionary _loadedDocs = new();
+ private const int CacheLimit = 64;
+ private readonly object _gate = new();
+ private readonly Dictionary _loadedDocs = new(StringComparer.OrdinalIgnoreCase);
///
/// Gets the summary documentation for a type.
@@ -92,33 +96,43 @@ public sealed class XmlDocLoader
private XDocument? GetDocument(Assembly assembly)
{
- var location = assembly.Location;
+ var location = AssemblyReferenceLoader.GetSourcePath(assembly);
if (string.IsNullOrEmpty(location))
return null;
- if (_loadedDocs.TryGetValue(location, out var cached))
- return cached;
-
var xmlPath = Path.ChangeExtension(location, ".xml");
- if (!File.Exists(xmlPath))
- {
- _loadedDocs[location] = null;
- return null;
- }
-
+ string? text;
try
{
- var doc = XDocument.Load(xmlPath);
- _loadedDocs[location] = doc;
- return doc;
+ text = CompilerFileSystem.FileExists(xmlPath) ? CompilerFileSystem.ReadAllText(xmlPath) : null;
}
- catch
+ catch (OperationCanceledException) { throw; }
+ catch (IOException) { return null; }
+ catch (UnauthorizedAccessException) { return null; }
+
+ lock (_gate)
{
- _loadedDocs[location] = null;
- return null;
+ if (_loadedDocs.TryGetValue(location, out CachedDocument? cached) &&
+ string.Equals(cached.Text, text, StringComparison.Ordinal))
+ {
+ return cached.Document;
+ }
+
+ XDocument? document = null;
+ if (text is not null)
+ {
+ try { document = XDocument.Parse(text); }
+ catch (System.Xml.XmlException) { }
+ }
+ if (!_loadedDocs.ContainsKey(location) && _loadedDocs.Count >= CacheLimit)
+ _loadedDocs.Remove(_loadedDocs.Keys.First());
+ _loadedDocs[location] = new CachedDocument(text, document);
+ return document;
}
}
+ private sealed record CachedDocument(string? Text, XDocument? Document);
+
private static string CleanupDocumentation(string text)
{
// Remove leading/trailing whitespace and normalize internal whitespace
diff --git a/src/SharpTS.LanguageServer/Handlers/AnalysisWorkspaceHandlers.cs b/src/SharpTS.LanguageServer/Handlers/AnalysisWorkspaceHandlers.cs
new file mode 100644
index 000000000..1301e0ddb
--- /dev/null
+++ b/src/SharpTS.LanguageServer/Handlers/AnalysisWorkspaceHandlers.cs
@@ -0,0 +1,47 @@
+using MediatR;
+using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities;
+using OmniSharp.Extensions.LanguageServer.Protocol.Models;
+using OmniSharp.Extensions.LanguageServer.Protocol.Workspace;
+using SharpTS.LanguageServer.Services;
+using FileSystemWatcher = OmniSharp.Extensions.LanguageServer.Protocol.Models.FileSystemWatcher;
+
+namespace SharpTS.LanguageServer.Handlers;
+
+/// Uses client file notifications for prompt release; physical validation is the fallback.
+public sealed class AnalysisWatchedFilesHandler(
+ SemanticAnalysisService analysis, DiagnosticsCoordinator diagnostics) : DidChangeWatchedFilesHandlerBase
+{
+ public override Task Handle(DidChangeWatchedFilesParams request, CancellationToken cancellationToken)
+ {
+ analysis.InvalidateAll();
+ diagnostics.RepublishAll();
+ return Unit.Task;
+ }
+
+ protected override DidChangeWatchedFilesRegistrationOptions CreateRegistrationOptions(
+ DidChangeWatchedFilesCapability capability, ClientCapabilities clientCapabilities) => new()
+ {
+ Watchers = new Container(new FileSystemWatcher
+ {
+ GlobPattern = new GlobPattern("**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json,csproj,dll,xml}"),
+ Kind = WatchKind.Create | WatchKind.Change | WatchKind.Delete,
+ }),
+ };
+}
+
+public sealed class AnalysisWorkspaceFoldersHandler(
+ NavigationWorkspaceContext workspace, SemanticAnalysisService analysis,
+ DiagnosticsCoordinator diagnostics) : DidChangeWorkspaceFoldersHandlerBase
+{
+ public override Task Handle(DidChangeWorkspaceFoldersParams request, CancellationToken cancellationToken)
+ {
+ workspace.Change(request.Event.Added.Select(folder => folder.Uri.GetFileSystemPath()),
+ request.Event.Removed.Select(folder => folder.Uri.GetFileSystemPath()));
+ analysis.InvalidateAll();
+ diagnostics.RepublishAll();
+ return Unit.Task;
+ }
+
+ protected override DidChangeWorkspaceFolderRegistrationOptions CreateRegistrationOptions(
+ ClientCapabilities clientCapabilities) => new() { Supported = true, ChangeNotifications = true };
+}
diff --git a/src/SharpTS.LanguageServer/Handlers/CompletionHandler.cs b/src/SharpTS.LanguageServer/Handlers/CompletionHandler.cs
index bc49c4a1b..299daf652 100644
--- a/src/SharpTS.LanguageServer/Handlers/CompletionHandler.cs
+++ b/src/SharpTS.LanguageServer/Handlers/CompletionHandler.cs
@@ -3,36 +3,58 @@
using OmniSharp.Extensions.LanguageServer.Protocol.Models;
using OmniSharp.Extensions.LanguageServer.Protocol.Server.Capabilities;
using SharpTS.LanguageServer.Services;
+using SharpTS.LanguageServer.Project;
namespace SharpTS.LanguageServer.Handlers;
-/// Completion of the builtin SharpTS decorators after an @ .
+/// SharpTS-specific completion in both modes, with checked semantic fallback in full mode.
public sealed class CompletionHandler : CompletionHandlerBase
{
private readonly DocumentStore _store;
private readonly DecoratorService _decorators;
private readonly GuiContractService _gui;
+ private readonly AnalysisMetadataProvider? _metadata;
+ private readonly SemanticCompletionService? _semantic;
+ private HashSet? _supportedKinds;
- public CompletionHandler(DocumentStore store, DecoratorService decorators, GuiContractService? gui = null)
+ public CompletionHandler(DocumentStore store, DecoratorService decorators, GuiContractService? gui = null,
+ AnalysisMetadataProvider? metadata = null, SemanticCompletionService? semantic = null)
{
_store = store;
_decorators = decorators;
_gui = gui ?? new GuiContractService();
+ _metadata = metadata;
+ _semantic = semantic;
}
- public override Task Handle(CompletionParams request, CancellationToken ct)
+ public override async Task Handle(CompletionParams request, CancellationToken ct)
{
- if (!_store.TryGetSnapshot(
+ ct.ThrowIfCancellationRequested();
+ if (!_store.TryCapture(
request.TextDocument.Uri.ToString(),
- out DocumentSnapshot? snapshot))
- return Task.FromResult(new CompletionList());
+ out DocumentRequestSnapshot? capture))
+ return new CompletionList();
+ using var guard = new EditorRequestGuard(_store, capture, _metadata, ct);
+ DocumentSnapshot snapshot = capture.Document;
var list = _gui.Completion(snapshot.FilePath, snapshot.Text, request.Position.Line, request.Position.Character) ??
_decorators.Completion(
snapshot.Text,
request.Position.Line,
request.Position.Character);
- return Task.FromResult(list ?? new CompletionList());
+ AnalysisValidation? validation = null;
+ if (list is null && _semantic is not null)
+ {
+ var semantic = await _semantic.CompletionAsync(capture, request.Position, ct).ConfigureAwait(false);
+ list = semantic.List;
+ validation = semantic.Validation;
+ if (_supportedKinds is not null)
+ list = new CompletionList(list.Items.Select(item => _supportedKinds.Contains(item.Kind) ? item :
+ item with { Kind = _supportedKinds.Contains(CompletionItemKind.Text) ? CompletionItemKind.Text : default }),
+ list.IsIncomplete);
+ }
+ ct.ThrowIfCancellationRequested();
+ return guard.IsCurrent(ct) && (validation?.IsCurrent(ct) ?? true) ? list ?? new CompletionList() : new CompletionList();
}
// No resolve step needed — items are fully populated up front.
@@ -41,9 +63,13 @@ public override Task Handle(CompletionItem request, Cancellation
protected override CompletionRegistrationOptions CreateRegistrationOptions(
CompletionCapability capability, ClientCapabilities clientCapabilities)
- => new()
+ {
+ _supportedKinds = capability?.CompletionItemKind?.ValueSet?.ToHashSet();
+ return new()
{
DocumentSelector = TextDocumentSelector.ForLanguage("typescript", "typescriptreact"),
- TriggerCharacters = new[] { "@", "<", " ", "=", "\"" }
+ TriggerCharacters = _semantic is null ? new[] { "@", "<", " ", "=", "\"" } :
+ new[] { "@", "<", " ", "=", "\"", ".", "?" }
};
+ }
}
diff --git a/src/SharpTS.LanguageServer/Handlers/DefinitionHandler.cs b/src/SharpTS.LanguageServer/Handlers/DefinitionHandler.cs
index b5ec6d20e..fa8b3756e 100644
--- a/src/SharpTS.LanguageServer/Handlers/DefinitionHandler.cs
+++ b/src/SharpTS.LanguageServer/Handlers/DefinitionHandler.cs
@@ -3,11 +3,12 @@
using OmniSharp.Extensions.LanguageServer.Protocol.Document;
using OmniSharp.Extensions.LanguageServer.Protocol.Models;
using SharpTS.LanguageServer.Services;
+using SharpTS.LanguageServer.Project;
namespace SharpTS.LanguageServer.Handlers;
///
-/// Serves textDocument/definition for checker-resolved value and type bindings.
+/// Serves textDocument/definition for checker-resolved lexical bindings and source members.
///
///
/// Registered only in so the VS Code extension continues
@@ -18,38 +19,42 @@ public sealed class DefinitionHandler : DefinitionHandlerBase
private readonly DocumentStore _store;
private readonly DefinitionService _definitions;
private readonly GuiContractService _gui;
+ private readonly AnalysisMetadataProvider? _metadata;
- public DefinitionHandler(DocumentStore store, DefinitionService definitions, GuiContractService? gui = null)
+ public DefinitionHandler(DocumentStore store, DefinitionService definitions, GuiContractService? gui = null,
+ AnalysisMetadataProvider? metadata = null)
{
_store = store;
_definitions = definitions;
_gui = gui ?? new GuiContractService();
+ _metadata = metadata;
}
- public override Task Handle(
+ public override async Task Handle(
DefinitionParams request,
CancellationToken ct)
{
string uri = request.TextDocument.Uri.ToString();
if (!_store.TryCapture(uri, out DocumentRequestSnapshot? snapshot))
- return Task.FromResult(null);
+ return null;
ct.ThrowIfCancellationRequested();
+ using var guard = new EditorRequestGuard(_store, snapshot, _metadata, ct);
Location? guiLocation = _gui.Definition(snapshot.Document.FilePath, snapshot.Document.Text,
request.Position.Line, request.Position.Character);
if (guiLocation is not null)
- return Task.FromResult(
- new LocationOrLocationLinks(new[] { new LocationOrLocationLink(guiLocation) }));
-
- var locations = _definitions.FindDefinitions(
- request.TextDocument.Uri.GetFileSystemPath(),
- snapshot.Document.Text,
- request.Position,
- snapshot.TextOverlay)
- .Select(location => new LocationOrLocationLink(location));
- return Task.FromResult(
- new LocationOrLocationLinks(locations));
+ {
+ ct.ThrowIfCancellationRequested();
+ return guard.IsCurrent(ct)
+ ? new LocationOrLocationLinks(new[] { new LocationOrLocationLink(guiLocation) }) : null;
+ }
+
+ var result = await _definitions.FindDefinitionsAsync(snapshot, request.Position, ct);
+ ct.ThrowIfCancellationRequested();
+ if (!guard.IsCurrent(ct) || !result.IsCurrent(ct))
+ return null;
+ return new LocationOrLocationLinks(result.Locations.Select(location => new LocationOrLocationLink(location)));
}
protected override DefinitionRegistrationOptions CreateRegistrationOptions(
diff --git a/src/SharpTS.LanguageServer/Handlers/HoverHandler.cs b/src/SharpTS.LanguageServer/Handlers/HoverHandler.cs
index 6360fa351..f9ef8ef00 100644
--- a/src/SharpTS.LanguageServer/Handlers/HoverHandler.cs
+++ b/src/SharpTS.LanguageServer/Handlers/HoverHandler.cs
@@ -3,41 +3,68 @@
using OmniSharp.Extensions.LanguageServer.Protocol.Models;
using OmniSharp.Extensions.LanguageServer.Protocol.Server.Capabilities;
using SharpTS.LanguageServer.Services;
+using SharpTS.LanguageServer.Project;
namespace SharpTS.LanguageServer.Handlers;
-/// Hover for SharpTS decorators (resolved .NET type + XML doc).
+/// Interop hover in both modes, with shared semantic fallback in full mode.
public sealed class HoverHandler : HoverHandlerBase
{
private readonly DocumentStore _store;
private readonly DecoratorService _decorators;
private readonly MemberHoverService _members;
private readonly GuiContractService _gui;
+ private readonly AnalysisMetadataProvider? _metadata;
+ private readonly SemanticHoverService? _semantic;
+ private MarkupKind _format = MarkupKind.PlainText;
- public HoverHandler(DocumentStore store, DecoratorService decorators, MemberHoverService members, GuiContractService? gui = null)
+ public HoverHandler(DocumentStore store, DecoratorService decorators, MemberHoverService members,
+ GuiContractService? gui = null, AnalysisMetadataProvider? metadata = null,
+ SemanticHoverService? semantic = null)
{
_store = store;
_decorators = decorators;
_members = members;
_gui = gui ?? new GuiContractService();
+ _metadata = metadata;
+ _semantic = semantic;
}
- public override Task Handle(HoverParams request, CancellationToken ct)
+ public override async Task Handle(HoverParams request, CancellationToken ct)
{
- if (!_store.TryGetSnapshot(
+ ct.ThrowIfCancellationRequested();
+ if (!_store.TryCapture(
request.TextDocument.Uri.ToString(),
- out DocumentSnapshot? snapshot))
- return Task.FromResult(null);
+ out DocumentRequestSnapshot? capture))
+ return null;
+ using var guard = new EditorRequestGuard(_store, capture, _metadata, ct);
+ DocumentSnapshot snapshot = capture.Document;
int line = request.Position.Line, ch = request.Position.Character;
- // Decorator hover first (cursor on @DotNetType / a builtin); then .NET member hover.
- return Task.FromResult(
+ // Preserve the existing priority without starting shared analysis for a cheap result.
+ Hover? result =
_gui.Hover(snapshot.FilePath, snapshot.Text, line, ch) ??
_decorators.Hover(snapshot.Text, line, ch) ??
- _members.Hover(snapshot.Text, line, ch));
+ (_semantic is null ? _members.Hover(snapshot.Text, line, ch, ct) :
+ _members.DeclarationHover(snapshot.Text, line, ch, ct));
+ AnalysisValidation? validation = null;
+ if (result is null && _semantic is not null)
+ {
+ var semantic = await _semantic.HoverAsync(capture, request.Position, _format, ct).ConfigureAwait(false);
+ result = semantic.Hover;
+ validation = semantic.Validation;
+ }
+ ct.ThrowIfCancellationRequested();
+ return guard.IsCurrent(ct) && (validation?.IsCurrent(ct) ?? true)
+ ? SemanticHoverService.AdaptMarkup(result, _format) : null;
}
protected override HoverRegistrationOptions CreateRegistrationOptions(
HoverCapability capability, ClientCapabilities clientCapabilities)
- => new() { DocumentSelector = TextDocumentSelector.ForLanguage("typescript", "typescriptreact") };
+ {
+ var preferred = capability?.ContentFormat?.FirstOrDefault(format => format == MarkupKind.Markdown ||
+ format == MarkupKind.PlainText);
+ _format = preferred == MarkupKind.Markdown ? MarkupKind.Markdown : MarkupKind.PlainText;
+ return new() { DocumentSelector = TextDocumentSelector.ForLanguage("typescript", "typescriptreact") };
+ }
}
diff --git a/src/SharpTS.LanguageServer/Handlers/ReferencesHandler.cs b/src/SharpTS.LanguageServer/Handlers/ReferencesHandler.cs
index 571677153..e54614289 100644
--- a/src/SharpTS.LanguageServer/Handlers/ReferencesHandler.cs
+++ b/src/SharpTS.LanguageServer/Handlers/ReferencesHandler.cs
@@ -29,25 +29,26 @@ public ReferencesHandler(
_workspace = workspace;
}
- public override Task Handle(
+ public override async Task Handle(
ReferenceParams request,
CancellationToken ct)
{
string uri = request.TextDocument.Uri.ToString();
if (!_store.TryCapture(uri, out DocumentRequestSnapshot? snapshot))
- return Task.FromResult(null);
+ return null;
ct.ThrowIfCancellationRequested();
- var locations = _references.FindReferences(
- request.TextDocument.Uri.GetFileSystemPath(),
- snapshot.Document.Text,
+ long? workspaceVersion = _workspace?.Version;
+ var result = await _references.FindReferenceResultAsync(
+ snapshot,
request.Position,
request.Context.IncludeDeclaration,
- snapshot.TextOverlay,
- _workspace?.SnapshotRoots());
- return Task.FromResult(
- new LocationContainer(locations));
+ _workspace?.SnapshotRoots(), cancellationToken: ct);
+ ct.ThrowIfCancellationRequested();
+ if (!_store.IsCurrent(uri, snapshot.Document.Version, snapshot.WorkspaceVersion) ||
+ _workspace?.Version != workspaceVersion || !result.IsCurrent(ct)) return null;
+ return new LocationContainer(result.Locations);
}
protected override ReferenceRegistrationOptions CreateRegistrationOptions(
diff --git a/src/SharpTS.LanguageServer/Handlers/RenameHandler.cs b/src/SharpTS.LanguageServer/Handlers/RenameHandler.cs
index 22a5c302d..f39318e1c 100644
--- a/src/SharpTS.LanguageServer/Handlers/RenameHandler.cs
+++ b/src/SharpTS.LanguageServer/Handlers/RenameHandler.cs
@@ -6,47 +6,65 @@
namespace SharpTS.LanguageServer.Handlers;
///
-/// Serves semantic rename only for complete configured-workspace symbol domains.
+/// Serves complete lexical workspace renames and separately proven, versioned private domains.
///
public sealed class RenameHandler : RenameHandlerBase
{
private readonly DocumentStore _store;
private readonly RenameService _rename;
private readonly NavigationWorkspaceContext _workspace;
+ private readonly PrivateRenameService? _privateRename;
+ private bool _supportsVersionedEdits;
public RenameHandler(
DocumentStore store,
RenameService rename,
- NavigationWorkspaceContext workspace)
+ NavigationWorkspaceContext workspace,
+ PrivateRenameService? privateRename = null)
{
_store = store;
_rename = rename;
_workspace = workspace;
+ _privateRename = privateRename;
}
- public override Task Handle(
+ public override async Task Handle(
RenameParams request,
CancellationToken ct)
{
string uri = request.TextDocument.Uri.ToString();
if (!_store.TryCapture(uri, out DocumentRequestSnapshot? snapshot))
- return Task.FromResult(null);
+ return null;
ct.ThrowIfCancellationRequested();
- return Task.FromResult(
- _rename.Rename(
- request.TextDocument.Uri.GetFileSystemPath(),
- snapshot.Document.Text,
- request.Position,
- request.NewName,
- snapshot.TextOverlay,
- _workspace.SnapshotRoots()));
+ long workspaceVersion = _workspace.Version;
+ if (_supportsVersionedEdits && _privateRename is not null)
+ {
+ var privateResult = await _privateRename.RenameAsync(snapshot, request.Position, request.NewName, ct)
+ .ConfigureAwait(false);
+ ct.ThrowIfCancellationRequested();
+ if (privateResult.IsPrivateContext)
+ return _store.IsCurrent(uri, snapshot.Document.Version, snapshot.WorkspaceVersion) &&
+ _workspace.Version == workspaceVersion && privateResult.IsCurrent(ct) ? privateResult.Value : null;
+ }
+ var result = await _rename.RenameAsync(snapshot, request.Position, request.NewName,
+ _workspace.SnapshotRoots(), ct);
+ ct.ThrowIfCancellationRequested();
+ return _store.IsCurrent(uri, snapshot.Document.Version, snapshot.WorkspaceVersion) &&
+ _workspace.Version == workspaceVersion && result.Domain.IsCurrent(ct) ? result.Value : null;
}
protected override RenameRegistrationOptions CreateRegistrationOptions(
RenameCapability capability,
ClientCapabilities clientCapabilities)
- => CreateRenameRegistrationOptions();
+ {
+ _supportsVersionedEdits = SupportsVersionedEdits(clientCapabilities);
+ return CreateRenameRegistrationOptions();
+ }
+
+ internal static bool SupportsVersionedEdits(ClientCapabilities? clientCapabilities) =>
+ clientCapabilities?.Workspace?.WorkspaceEdit is
+ { IsSupported: true, Value: { DocumentChanges: true } };
internal static RenameRegistrationOptions CreateRenameRegistrationOptions() =>
new()
@@ -66,40 +84,51 @@ public sealed class PrepareRenameHandler : PrepareRenameHandlerBase
private readonly DocumentStore _store;
private readonly RenameService _rename;
private readonly NavigationWorkspaceContext _workspace;
+ private readonly PrivateRenameService? _privateRename;
+ private bool _supportsVersionedEdits;
public PrepareRenameHandler(
DocumentStore store,
RenameService rename,
- NavigationWorkspaceContext workspace)
+ NavigationWorkspaceContext workspace,
+ PrivateRenameService? privateRename = null)
{
_store = store;
_rename = rename;
_workspace = workspace;
+ _privateRename = privateRename;
}
- public override Task Handle(
+ public override async Task Handle(
PrepareRenameParams request,
CancellationToken ct)
{
string uri = request.TextDocument.Uri.ToString();
if (!_store.TryCapture(uri, out DocumentRequestSnapshot? snapshot))
- return Task.FromResult(null);
+ return null;
ct.ThrowIfCancellationRequested();
- OmniSharp.Extensions.LanguageServer.Protocol.Models.Range? range = _rename.Prepare(
- request.TextDocument.Uri.GetFileSystemPath(),
- snapshot.Document.Text,
- request.Position,
- snapshot.TextOverlay,
- _workspace.SnapshotRoots());
- return Task.FromResult(
- range is null
- ? null
- : new RangeOrPlaceholderRange(range));
+ long workspaceVersion = _workspace.Version;
+ if (_supportsVersionedEdits && _privateRename is not null)
+ {
+ var privateResult = await _privateRename.PrepareAsync(snapshot, request.Position, ct).ConfigureAwait(false);
+ ct.ThrowIfCancellationRequested();
+ if (privateResult.IsPrivateContext)
+ return _store.IsCurrent(uri, snapshot.Document.Version, snapshot.WorkspaceVersion) &&
+ _workspace.Version == workspaceVersion && privateResult.IsCurrent(ct) ? privateResult.Value : null;
+ }
+ var result = await _rename.PrepareAsync(snapshot, request.Position, _workspace.SnapshotRoots(), ct);
+ ct.ThrowIfCancellationRequested();
+ if (!_store.IsCurrent(uri, snapshot.Document.Version, snapshot.WorkspaceVersion) ||
+ _workspace.Version != workspaceVersion || !result.Domain.IsCurrent(ct)) return null;
+ return result.Value is null ? null : new RangeOrPlaceholderRange(result.Value);
}
protected override RenameRegistrationOptions CreateRegistrationOptions(
RenameCapability capability,
ClientCapabilities clientCapabilities)
- => RenameHandler.CreateRenameRegistrationOptions();
+ {
+ _supportsVersionedEdits = RenameHandler.SupportsVersionedEdits(clientCapabilities);
+ return RenameHandler.CreateRenameRegistrationOptions();
+ }
}
diff --git a/src/SharpTS.LanguageServer/Handlers/SignatureHelpHandler.cs b/src/SharpTS.LanguageServer/Handlers/SignatureHelpHandler.cs
index 44ab48431..f3b2e7f65 100644
--- a/src/SharpTS.LanguageServer/Handlers/SignatureHelpHandler.cs
+++ b/src/SharpTS.LanguageServer/Handlers/SignatureHelpHandler.cs
@@ -3,40 +3,68 @@
using OmniSharp.Extensions.LanguageServer.Protocol.Models;
using OmniSharp.Extensions.LanguageServer.Protocol.Server.Capabilities;
using SharpTS.LanguageServer.Services;
+using SharpTS.LanguageServer.Project;
+using SharpTS.Parsing;
namespace SharpTS.LanguageServer.Handlers;
-/// Signature help for builtin SharpTS decorator calls.
+/// Decorator help in both modes, with shared call signatures in full mode.
public sealed class SignatureHelpHandler : SignatureHelpHandlerBase
{
private readonly DocumentStore _store;
private readonly DecoratorService _decorators;
+ private readonly AnalysisMetadataProvider? _metadata;
+ private readonly SemanticSignatureHelpService? _semantic;
+ private bool _labelOffsetSupport;
+ private bool _activeParameterSupport;
- public SignatureHelpHandler(DocumentStore store, DecoratorService decorators)
+ public SignatureHelpHandler(DocumentStore store, DecoratorService decorators, AnalysisMetadataProvider? metadata = null,
+ SemanticSignatureHelpService? semantic = null)
{
_store = store;
_decorators = decorators;
+ _metadata = metadata;
+ _semantic = semantic;
}
- public override Task Handle(SignatureHelpParams request, CancellationToken ct)
+ public override async Task Handle(SignatureHelpParams request, CancellationToken ct)
{
- if (!_store.TryGetSnapshot(
+ ct.ThrowIfCancellationRequested();
+ if (!_store.TryCapture(
request.TextDocument.Uri.ToString(),
- out DocumentSnapshot? snapshot))
- return Task.FromResult(null);
+ out DocumentRequestSnapshot? capture))
+ return null;
+ var lines = new LineIndex(capture.Document.Text);
+ int offset = lines.ToOffset(request.Position.Line + 1, request.Position.Character + 1);
+ var (line, column) = lines.ToPosition(offset);
+ if (line - 1 != request.Position.Line || column - 1 != request.Position.Character) return null;
+ using var guard = new EditorRequestGuard(_store, capture, _metadata, ct);
- return Task.FromResult(
- _decorators.SignatureHelp(
- snapshot.Text,
+ SignatureHelp? result = _decorators.SignatureHelp(
+ capture.Document.Text,
request.Position.Line,
- request.Position.Character));
+ request.Position.Character);
+ AnalysisValidation? validation = null;
+ if (result is null && _semantic is not null)
+ {
+ var semantic = await _semantic.SignatureHelpAsync(capture, request.Position,
+ _labelOffsetSupport, _activeParameterSupport, ct).ConfigureAwait(false);
+ result = semantic.Help;
+ validation = semantic.Validation;
+ }
+ ct.ThrowIfCancellationRequested();
+ return guard.IsCurrent(ct) && (validation?.IsCurrent(ct) ?? true) ? result : null;
}
protected override SignatureHelpRegistrationOptions CreateRegistrationOptions(
SignatureHelpCapability capability, ClientCapabilities clientCapabilities)
- => new()
+ {
+ _labelOffsetSupport = capability?.SignatureInformation?.ParameterInformation?.LabelOffsetSupport == true;
+ _activeParameterSupport = capability?.SignatureInformation?.ActiveParameterSupport == true;
+ return new()
{
DocumentSelector = TextDocumentSelector.ForLanguage("typescript", "typescriptreact"),
TriggerCharacters = new[] { "(", "," }
};
+ }
}
diff --git a/src/SharpTS.LanguageServer/Handlers/TextDocumentSyncHandler.cs b/src/SharpTS.LanguageServer/Handlers/TextDocumentSyncHandler.cs
index fd971212a..c26cbd768 100644
--- a/src/SharpTS.LanguageServer/Handlers/TextDocumentSyncHandler.cs
+++ b/src/SharpTS.LanguageServer/Handlers/TextDocumentSyncHandler.cs
@@ -17,13 +17,16 @@ public sealed class TextDocumentSyncHandler : TextDocumentSyncHandlerBase
{
private readonly DocumentStore _store;
private readonly DiagnosticsCoordinator _diagnostics;
+ private readonly SemanticAnalysisService? _analysis;
public TextDocumentSyncHandler(
DocumentStore store,
- DiagnosticsCoordinator diagnostics)
+ DiagnosticsCoordinator diagnostics,
+ SemanticAnalysisService? analysis = null)
{
_store = store;
_diagnostics = diagnostics;
+ _analysis = analysis;
}
public override TextDocumentAttributes GetTextDocumentAttributes(DocumentUri uri) => new(uri, "typescript");
@@ -36,6 +39,7 @@ public override Task Handle(DidOpenTextDocumentParams request, Cancellatio
request.TextDocument.Text,
request.TextDocument.Version ?? 0))
{
+ _analysis?.InvalidateAll();
_diagnostics.Queue(uri);
}
return Unit.Task;
@@ -53,6 +57,7 @@ public override Task Handle(DidChangeTextDocumentParams request, Cancellat
version,
request.ContentChanges))
{
+ _analysis?.InvalidateAll();
_diagnostics.Queue(uri);
}
return Unit.Task;
@@ -63,7 +68,10 @@ public override Task Handle(DidChangeTextDocumentParams request, Cancellat
public override Task Handle(DidCloseTextDocumentParams request, CancellationToken ct)
{
if (_store.Remove(request.TextDocument.Uri.ToString()) is { } closed)
+ {
+ _analysis?.InvalidateAll();
_diagnostics.Close(closed);
+ }
return Unit.Task;
}
diff --git a/src/SharpTS.LanguageServer/Program.cs b/src/SharpTS.LanguageServer/Program.cs
index 4241b76ba..628880d50 100644
--- a/src/SharpTS.LanguageServer/Program.cs
+++ b/src/SharpTS.LanguageServer/Program.cs
@@ -1,4 +1,3 @@
-using SharpTS.Compilation;
using SharpTS.LanguageServer;
using SharpTS.LanguageServer.Conversions;
using SharpTS.LanguageServer.Project;
@@ -51,38 +50,31 @@ await Console.Error.WriteLineAsync(
try
{
- // Resolve @DotNetType targets against the project's referenced assemblies (via
- // MetadataLoadContext). With no project/refs the loader still resolves the BCL.
- var paths = new List(references);
- if (projectFile != null && File.Exists(projectFile))
- paths.AddRange(CsprojParser.Parse(projectFile));
-
- // sharpts.json (walked up from the workspace root = CWD, matching the CLI's
- // discovery from the entry script) supplies more paths for the SAME loader:
- // Resolve, not Load — the editor process inspects workspace assemblies through
- // the MetadataLoadContext and never executes their code. Read once at startup,
- // like --project. A broken manifest must not kill the server: log and continue.
+ // Acquire manifest packages once at startup, preserving the existing startup behavior.
+ // All later configuration validation and metadata captures are read-only: they inspect
+ // already restored assets and never launch restore during editor requests.
try
{
- var refSet = SharpTS.References.DotNetReferences.Resolve(Environment.CurrentDirectory, []);
- paths.AddRange(refSet.References.Select(r => r.Path));
+ SharpTS.References.DotNetReferences.Resolve(Environment.CurrentDirectory, []);
}
catch (Exception ex)
{
await Console.Error.WriteLineAsync($"[LSP] sharpts.json ignored: {ex.Message}");
}
- using var loader = new ReloadingAssemblyReferenceLoader(paths, sdkPath);
- Func> typeNames = () => loader.GetAllPublicTypes()
- .Select(t => t.FullName)
- .Where(n => !string.IsNullOrEmpty(n))
- .Cast();
+ using var metadata = new AnalysisMetadataProvider(
+ projectFile,
+ references,
+ sdkPath,
+ Environment.CurrentDirectory,
+ message => Console.Error.WriteLine($"[LSP] {message}"));
await SharpTSLanguageServer.RunAsync(
- loader.TryResolve,
- typeNames,
+ metadata.Resolve,
+ metadata.GetTypeNames,
mode,
- diagnosticMode);
+ diagnosticMode,
+ metadataProvider: metadata);
}
catch (Exception ex)
{
diff --git a/src/SharpTS.LanguageServer/Project/AnalysisMetadataProvider.cs b/src/SharpTS.LanguageServer/Project/AnalysisMetadataProvider.cs
new file mode 100644
index 000000000..941c0e398
--- /dev/null
+++ b/src/SharpTS.LanguageServer/Project/AnalysisMetadataProvider.cs
@@ -0,0 +1,267 @@
+using System.Collections.Immutable;
+using System.Security.Cryptography;
+using System.Text;
+using SharpTS.Compilation;
+using SharpTS.IO;
+using SharpTS.References;
+
+namespace SharpTS.LanguageServer.Project;
+
+///
+/// Captures read-only project/manifest inputs and one stable CLR metadata generation. Refreshing
+/// never loads executable assemblies, restores packages, or launches an external process.
+///
+public sealed class AnalysisMetadataProvider : IDisposable
+{
+ private readonly object _gate = new();
+ private readonly AsyncLocal _ambient = new();
+ private readonly string? _projectFile;
+ private readonly string[] _references;
+ private readonly string? _sdkPath;
+ private readonly string _startDirectory;
+ private readonly Action? _log;
+ private ReloadingAssemblyReferenceLoader? _loader;
+ private string? _inputKey;
+ private string? _lastErrors;
+ private int _generation;
+ private bool _disposed;
+
+ public AnalysisMetadataProvider(
+ string? projectFile = null,
+ IEnumerable? references = null,
+ string? sdkPath = null,
+ string? startDirectory = null,
+ Action? log = null)
+ {
+ _projectFile = projectFile is null ? null : Path.GetFullPath(projectFile);
+ _references = (references ?? []).Select(Path.GetFullPath).ToArray();
+ _sdkPath = sdkPath;
+ _startDirectory = Path.GetFullPath(startDirectory ?? Environment.CurrentDirectory);
+ _log = log;
+ }
+
+ /// The ambient captured generation, suitable for a request-local query cache.
+ public int CurrentGeneration => _ambient.Value?.Generation ?? RefreshGeneration();
+
+ public AnalysisMetadataView Capture()
+ {
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ var inputs = new MetadataInputCapture(CompilerFileSystem.Current);
+ using var fileSystemScope = CompilerFileSystem.Use(inputs, CompilerFileSystem.CancellationToken);
+ var paths = new List(_references);
+ var errors = new List();
+ if (_projectFile is not null)
+ {
+ try
+ {
+ paths.AddRange(CsprojParser.Parse(_projectFile));
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex) { errors.Add($"project references unavailable: {ex.Message}"); }
+ }
+ try
+ {
+ ReferenceSet set = DotNetReferences.ResolveReadOnly(_startDirectory, []);
+ paths.AddRange(set.References.Select(reference => reference.Path));
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex) { errors.Add($"sharpts.json references unavailable: {ex.Message}"); }
+
+ string[] distinctPaths = paths.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ foreach (string path in distinctPaths)
+ {
+ try
+ {
+ if (!CompilerFileSystem.FileExists(path))
+ errors.Add($"reference not found: {path}");
+ }
+ catch (Exception error) when (error is IOException or UnauthorizedAccessException)
+ { errors.Add($"reference unreadable: {path}: {error.Message}"); }
+ }
+ _loader ??= new ReloadingAssemblyReferenceLoader(distinctPaths, _sdkPath);
+ _loader.UpdateReferences(distinctPaths);
+ ReloadingAssemblyReferenceLoader.AssemblyReferenceSnapshot snapshot = _loader.AcquireSnapshot();
+ try
+ {
+ errors.AddRange(snapshot.Failures);
+ string errorText = string.Join("\n", errors.Distinct(StringComparer.Ordinal));
+ string key = $"{snapshot.Generation}:{inputs.Fingerprint()}:{errorText}";
+ if (_inputKey is not null && !string.Equals(_inputKey, key, StringComparison.Ordinal))
+ _generation = checked(_generation + 1);
+ _inputKey = key;
+ if (!string.Equals(_lastErrors, errorText, StringComparison.Ordinal))
+ {
+ _lastErrors = errorText;
+ foreach (string error in errors)
+ _log?.Invoke(error);
+ }
+ return new AnalysisMetadataView(this, snapshot, _generation, errors.Count == 0, distinctPaths);
+ }
+ catch
+ {
+ snapshot.Dispose();
+ throw;
+ }
+ }
+ }
+
+ ///
+ /// Revalidates current configuration and assembly content even inside an older metadata scope.
+ /// Call outside any memoized compiler filesystem scope when checking physical currentness.
+ ///
+ public int RefreshGeneration()
+ {
+ using AnalysisMetadataView view = Capture();
+ return view.Generation;
+ }
+
+ /// Existing editor callbacks resolve through their ambient captured view.
+ public Type? Resolve(string fullName)
+ {
+ if (_ambient.Value is { } view)
+ return view.Resolve(fullName);
+ lock (_gate)
+ {
+ // Preserve raw-Type compatibility for unscoped consumers. Production requests enter
+ // a view scope and release their contexts explicitly instead of taking this path.
+ using AnalysisMetadataView capture = Capture();
+ return _loader!.TryResolve(fullName);
+ }
+ }
+
+ public IEnumerable GetTypeNames()
+ {
+ if (_ambient.Value is { } view)
+ return view.GetTypeNames();
+ using AnalysisMetadataView capture = Capture();
+ return capture.GetTypeNames().ToArray();
+ }
+
+ internal IDisposable EnterScope(AnalysisMetadataView view)
+ {
+ AnalysisMetadataView? previous = _ambient.Value;
+ AnalysisMetadataView retained = view.Retain();
+ _ambient.Value = retained;
+ return new MetadataScope(this, previous, retained);
+ }
+
+ public void Dispose()
+ {
+ lock (_gate)
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ _loader?.Dispose();
+ }
+ }
+
+ private sealed class MetadataScope(
+ AnalysisMetadataProvider owner,
+ AnalysisMetadataView? previous,
+ AnalysisMetadataView retained) : IDisposable
+ {
+ private bool _disposed;
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ owner._ambient.Value = previous;
+ retained.Dispose();
+ }
+ }
+
+ ///
+ /// Memoizes only one capture's read-only inputs so repeated parser probes cannot join two
+ /// configurations. Its digest includes failed probes and inventories as well as text reads.
+ /// The enclosing analysis recorder still observes every underlying read.
+ ///
+ private sealed class MetadataInputCapture(ICompilerFileSystem inner) : ICompilerFileSystem
+ {
+ private readonly Dictionary _files = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary _directories = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary _text = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary _attributes = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary> _inventories = new(StringComparer.Ordinal);
+
+ public bool FileExists(string path) => Memoize(_files, Path.GetFullPath(path), inner.FileExists);
+ public bool DirectoryExists(string path) => Memoize(_directories, Path.GetFullPath(path), inner.DirectoryExists);
+ public string ReadAllText(string path) => Memoize(_text, Path.GetFullPath(path), inner.ReadAllText);
+ public FileAttributes GetAttributes(string path) => Memoize(_attributes, Path.GetFullPath(path), inner.GetAttributes);
+
+ public IReadOnlyList EnumerateFiles(string path, string searchPattern, EnumerationOptions options) =>
+ Inventory("files", path, searchPattern, options, inner.EnumerateFiles);
+ public IReadOnlyList EnumerateDirectories(string path, string searchPattern, EnumerationOptions options) =>
+ Inventory("directories", path, searchPattern, options, inner.EnumerateDirectories);
+
+ private IReadOnlyList Inventory(string kind, string path, string pattern, EnumerationOptions options,
+ Func> read)
+ {
+ string absolute = Path.GetFullPath(path);
+ string key = $"{kind}:{absolute}:{pattern}:{options.RecurseSubdirectories}:" +
+ $"{options.IgnoreInaccessible}:{options.AttributesToSkip}:{options.MatchType}:" +
+ $"{options.MatchCasing}:{options.ReturnSpecialDirectories}:{options.MaxRecursionDepth}";
+ if (!_inventories.TryGetValue(key, out IReadOnlyList? paths))
+ {
+ paths = read(absolute, pattern, options).ToImmutableArray();
+ _inventories.Add(key, paths);
+ }
+ return paths;
+ }
+
+ public string Fingerprint()
+ {
+ IEnumerable records = _files.Select(pair => $"file:{pair.Key}:{pair.Value}")
+ .Concat(_directories.Select(pair => $"directory:{pair.Key}:{pair.Value}"))
+ .Concat(_text.Select(pair => $"text:{pair.Key}:{Hash(pair.Value)}"))
+ .Concat(_attributes.Select(pair => $"attributes:{pair.Key}:{pair.Value}"))
+ .Concat(_inventories.Select(pair => $"inventory:{pair.Key}:{Hash(string.Join('\n', pair.Value))}"));
+ return Hash(string.Join('\n', records.Order(StringComparer.Ordinal)));
+ }
+
+ private static TValue Memoize(Dictionary values, string path, Func read)
+ {
+ if (!values.TryGetValue(path, out TValue? value))
+ {
+ value = read(path);
+ values.Add(path, value);
+ }
+ return value;
+ }
+
+ private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
+ }
+}
+
+///
+/// One immutable CLR metadata view. Retain a lease for every cache entry or active operation that
+/// may still use a reflected Type; dispose it when that operation or cache ownership ends.
+///
+public sealed class AnalysisMetadataView : IDisposable
+{
+ private readonly AnalysisMetadataProvider _owner;
+ private readonly ReloadingAssemblyReferenceLoader.AssemblyReferenceSnapshot _snapshot;
+
+ internal AnalysisMetadataView(AnalysisMetadataProvider owner,
+ ReloadingAssemblyReferenceLoader.AssemblyReferenceSnapshot snapshot,
+ int generation, bool isComplete, IReadOnlyList referencePaths)
+ {
+ _owner = owner;
+ _snapshot = snapshot;
+ Generation = generation;
+ IsComplete = isComplete;
+ ReferencePaths = referencePaths.ToImmutableArray();
+ }
+
+ public int Generation { get; }
+ public bool IsComplete { get; }
+ public IReadOnlyList ReferencePaths { get; }
+ public Type? Resolve(string fullName) => _snapshot.TryResolve(fullName);
+ public IEnumerable GetTypeNames() => _snapshot.GetAllPublicTypes()
+ .Select(type => type.FullName).OfType().ToImmutableArray();
+ public IDisposable EnterScope() => _owner.EnterScope(this);
+ public AnalysisMetadataView Retain() => new(_owner, _snapshot.Retain(), Generation, IsComplete, ReferencePaths);
+ public void Dispose() => _snapshot.Dispose();
+}
diff --git a/src/SharpTS.LanguageServer/Project/CsprojParser.cs b/src/SharpTS.LanguageServer/Project/CsprojParser.cs
index 93e3377d3..c6d0c3b82 100644
--- a/src/SharpTS.LanguageServer/Project/CsprojParser.cs
+++ b/src/SharpTS.LanguageServer/Project/CsprojParser.cs
@@ -1,4 +1,5 @@
using System.Xml.Linq;
+using SharpTS.IO;
namespace SharpTS.LanguageServer.Project;
@@ -12,16 +13,17 @@ public static class CsprojParser
///
public static List Parse(string csprojPath)
{
- if (!File.Exists(csprojPath))
+ if (!CompilerFileSystem.FileExists(csprojPath))
throw new FileNotFoundException($"Project file not found: {csprojPath}");
- var doc = XDocument.Load(csprojPath);
+ var doc = XDocument.Parse(CompilerFileSystem.ReadAllText(csprojPath));
var projectDir = Path.GetDirectoryName(Path.GetFullPath(csprojPath)) ?? ".";
var references = new List();
// Parse elements (direct assembly references)
foreach (var refElement in doc.Descendants("Reference"))
{
+ CompilerFileSystem.ThrowIfCancellationRequested();
var hintPath = refElement.Element("HintPath")?.Value;
if (!string.IsNullOrEmpty(hintPath))
{
@@ -29,7 +31,7 @@ public static List Parse(string csprojPath)
// (csproj files often use backslashes even when targeting Unix)
hintPath = hintPath.Replace('\\', Path.DirectorySeparatorChar);
var fullPath = Path.GetFullPath(Path.Combine(projectDir, hintPath));
- if (File.Exists(fullPath))
+ if (CompilerFileSystem.FileExists(fullPath))
references.Add(fullPath);
}
}
@@ -38,6 +40,7 @@ public static List Parse(string csprojPath)
var packageReferences = new List<(string Id, string Version)>();
foreach (var pkgElement in doc.Descendants("PackageReference"))
{
+ CompilerFileSystem.ThrowIfCancellationRequested();
var id = pkgElement.Attribute("Include")?.Value;
var version = pkgElement.Attribute("Version")?.Value
?? pkgElement.Element("Version")?.Value;
@@ -52,13 +55,14 @@ public static List Parse(string csprojPath)
var nugetPath = GetNuGetPackagesPath();
foreach (var (id, version) in packageReferences)
{
+ CompilerFileSystem.ThrowIfCancellationRequested();
var packagePath = Path.Combine(nugetPath, id.ToLowerInvariant(), version);
- if (Directory.Exists(packagePath))
+ if (CompilerFileSystem.DirectoryExists(packagePath))
{
var libPath = FindBestTfmLib(packagePath);
if (libPath != null)
{
- foreach (var dll in Directory.GetFiles(libPath, "*.dll"))
+ foreach (var dll in CompilerFileSystem.EnumerateFiles(libPath, "*.dll"))
{
references.Add(dll);
}
@@ -73,7 +77,7 @@ private static string GetNuGetPackagesPath()
{
// Check NUGET_PACKAGES environment variable first
var envPath = Environment.GetEnvironmentVariable("NUGET_PACKAGES");
- if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath))
+ if (!string.IsNullOrEmpty(envPath) && CompilerFileSystem.DirectoryExists(envPath))
return envPath;
// Default to user profile location
@@ -84,7 +88,7 @@ private static string GetNuGetPackagesPath()
private static string? FindBestTfmLib(string packagePath)
{
var libPath = Path.Combine(packagePath, "lib");
- if (!Directory.Exists(libPath))
+ if (!CompilerFileSystem.DirectoryExists(libPath))
return null;
// Prefer newer .NET versions, then netstandard
@@ -97,13 +101,14 @@ private static string GetNuGetPackagesPath()
foreach (var tfm in tfmPreferences)
{
+ CompilerFileSystem.ThrowIfCancellationRequested();
var tfmPath = Path.Combine(libPath, tfm);
- if (Directory.Exists(tfmPath))
+ if (CompilerFileSystem.DirectoryExists(tfmPath))
return tfmPath;
}
// Fall back to first available
- var dirs = Directory.GetDirectories(libPath);
- return dirs.Length > 0 ? dirs[0] : null;
+ var dirs = CompilerFileSystem.EnumerateDirectories(libPath);
+ return dirs.Count > 0 ? dirs[0] : null;
}
}
diff --git a/src/SharpTS.LanguageServer/Services/AnalysisSnapshot.cs b/src/SharpTS.LanguageServer/Services/AnalysisSnapshot.cs
new file mode 100644
index 000000000..0cc62f360
--- /dev/null
+++ b/src/SharpTS.LanguageServer/Services/AnalysisSnapshot.cs
@@ -0,0 +1,146 @@
+using System.Collections.Frozen;
+using System.Collections.Immutable;
+using SharpTS.Parsing;
+using SharpTS.TypeSystem;
+using SharpDiagnostic = SharpTS.Diagnostics.Diagnostic;
+using SharpDiagnosticSeverity = SharpTS.Diagnostics.DiagnosticSeverity;
+
+namespace SharpTS.LanguageServer.Services;
+
+///
+/// The parser and checker configuration used for one completed analysis.
+///
+internal sealed record AnalysisOptions(
+ DecoratorMode DecoratorMode,
+ JsxParseOptions JsxOptions,
+ TypeCheckerOptions CheckerOptions);
+
+/// A captured edge in the checked module/reference graph.
+internal sealed record AnalysisDependency(string SourcePath, string TargetPath);
+
+///
+/// One source capture and its parser output. Tokens and AST objects belong to this analysis;
+/// collection copies prevent parser/module list mutations from changing the published shape.
+///
+///
+/// AST nodes and source span tables preserve reference identity with the TypeMap and bindings.
+/// They must never be parsed into, checked again, or otherwise mutated after publication.
+/// Cursor-specific recovery must create separate artifacts instead of editing this document.
+///
+internal sealed class AnalysisDocument
+{
+ public AnalysisDocument(
+ SourceDocument document,
+ IReadOnlyList tokens,
+ IReadOnlyList statements,
+ IReadOnlyList parseDiagnostics,
+ bool hitParseErrorLimit = false)
+ {
+ Document = document;
+ Tokens = tokens.ToImmutableArray();
+ Statements = statements.ToImmutableArray();
+ ParseDiagnostics = AnalysisSnapshot.CopyDiagnostics(parseDiagnostics);
+ HitParseErrorLimit = hitParseErrorLimit;
+ HasRecoveredSyntax = hitParseErrorLimit ||
+ ParseDiagnostics.Any(diagnostic => diagnostic.Severity == SharpDiagnosticSeverity.Error);
+ }
+
+ public SourceDocument Document { get; }
+ public IReadOnlyList Tokens { get; }
+ public IReadOnlyList Statements { get; }
+ public EditorSyntaxIndex? Syntax => Document.EditorSyntax;
+ public IReadOnlyList ParseDiagnostics { get; }
+ public bool HitParseErrorLimit { get; }
+ public bool HasRecoveredSyntax { get; }
+}
+
+///
+/// Completed semantic data for one connected source component. No checker or resolver is retained
+/// or exposed, and callers can only query the build-owned TypeMap.
+///
+///
+/// Construction transfers ownership of the TypeMap, documents, and AST graph to the snapshot.
+/// The builder must finish checking before construction and must not recheck or mutate those
+/// objects afterward. Cache validation and caller cancellation belong to the analysis service,
+/// allowing multiple requests to safely query the same completed capture.
+///
+internal sealed class AnalysisSnapshot
+{
+ private readonly TypeMap _typeMap;
+ private readonly FrozenDictionary _documentsByPath;
+
+ public AnalysisSnapshot(
+ FrozenBindingIndex bindings,
+ TypeMap typeMap,
+ IReadOnlyList documents,
+ IReadOnlyList diagnostics,
+ NavigationGraphScope scope,
+ bool hasRecoveredSyntax = false,
+ bool hasPartialSemantics = false,
+ AnalysisOptions? options = null,
+ IReadOnlyList? dependencies = null,
+ FrozenMemberIndex? members = null,
+ FrozenEditorSemanticIndex? editorFacts = null)
+ {
+ Bindings = bindings;
+ Members = members ?? FrozenMemberIndex.Empty;
+ EditorFacts = editorFacts ?? FrozenEditorSemanticIndex.Empty;
+ _typeMap = typeMap;
+ Documents = documents.ToImmutableArray();
+ _documentsByPath = documents.ToFrozenDictionary(
+ document => NormalizeDocumentPath(document.Document.Path),
+ StringComparer.OrdinalIgnoreCase);
+ Diagnostics = CopyDiagnostics(diagnostics);
+ Scope = scope with { RootFiles = scope.RootFiles.ToImmutableArray() };
+ HasRecoveredSyntax = hasRecoveredSyntax ||
+ documents.Any(document => document.HasRecoveredSyntax);
+ HasPartialSemantics = hasPartialSemantics || HasRecoveredSyntax;
+ Options = options;
+ Dependencies = dependencies?.ToImmutableArray() ?? ImmutableArray.Empty;
+ ClassTypes = typeMap.ClassTypes.ToFrozenDictionary(StringComparer.Ordinal);
+ }
+
+ public FrozenBindingIndex Bindings { get; }
+ public FrozenMemberIndex Members { get; }
+ public FrozenEditorSemanticIndex EditorFacts { get; }
+ public IReadOnlyList Documents { get; }
+ public IReadOnlyList Diagnostics { get; }
+ public NavigationGraphScope Scope { get; }
+ public bool HasRecoveredSyntax { get; }
+ public bool HasPartialSemantics { get; }
+ public AnalysisOptions? Options { get; }
+ public IReadOnlyList Dependencies { get; }
+ public IReadOnlyDictionary ClassTypes { get; }
+ public int TypeCount => _typeMap.Count;
+
+ public bool TryGetDocument(string path, out AnalysisDocument? document) =>
+ _documentsByPath.TryGetValue(NormalizeDocumentPath(path), out document);
+
+ private static string NormalizeDocumentPath(string path) =>
+ Path.IsPathFullyQualified(path) ? Path.GetFullPath(path) : path;
+
+ public TypeInfo? GetType(Expr expression) => _typeMap.Get(expression);
+
+ public bool TryGetType(Expr expression, out TypeInfo? type) =>
+ _typeMap.TryGet(expression, out type);
+
+ public TypeInfo.Class? GetClassType(Stmt.Class declaration) =>
+ _typeMap.GetClassType(declaration);
+
+ public TypeInfo.Class? GetClassType(string className) => _typeMap.GetClassType(className);
+
+ public TypeInfo.Class? GetClassExprType(Expr.ClassExpr expression) =>
+ _typeMap.GetClassExprType(expression);
+
+ public TypeInfo.Function? GetFunctionType(string functionName) =>
+ _typeMap.GetFunctionType(functionName);
+
+ internal static IReadOnlyList CopyDiagnostics(
+ IReadOnlyList diagnostics) =>
+ diagnostics.Select(diagnostic => diagnostic.Properties is null
+ ? diagnostic
+ : diagnostic with
+ {
+ Properties = diagnostic.Properties.ToFrozenDictionary(StringComparer.Ordinal),
+ }).ToImmutableArray();
+}
diff --git a/src/SharpTS.LanguageServer/Services/DecoratorService.cs b/src/SharpTS.LanguageServer/Services/DecoratorService.cs
index b40bbf41c..372dab23d 100644
--- a/src/SharpTS.LanguageServer/Services/DecoratorService.cs
+++ b/src/SharpTS.LanguageServer/Services/DecoratorService.cs
@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
+using System.Collections.Immutable;
using OmniSharp.Extensions.LanguageServer.Protocol.Models;
using SharpTS.LanguageServer.Documentation;
using SharpTS.Runtime.DotNet;
@@ -15,19 +16,40 @@ public sealed class DecoratorService
private readonly Func _resolve;
private readonly Func>? _typeNamesProvider;
private readonly XmlDocLoader _xmlDoc = new();
- private List? _typeNameCache;
-
- public DecoratorService(Func? resolve = null, Func>? typeNames = null)
+ private readonly Func? _metadataGeneration;
+ private readonly object _typeNameGate = new();
+ private IReadOnlyList? _typeNameCache;
+ private int _typeNameGeneration;
+
+ public DecoratorService(
+ Func? resolve = null,
+ Func>? typeNames = null,
+ Func? metadataGeneration = null)
{
_resolve = resolve ?? DotNetTypeRegistry.Resolve;
_typeNamesProvider = typeNames;
+ _metadataGeneration = metadataGeneration;
}
- // Known public type names from the project's referenced assemblies (cached — the loaded
- // set is fixed for the session). Empty when no resolver/loader supplies them.
- private List KnownTypeNames()
- => _typeNameCache ??= (_typeNamesProvider?.Invoke() ?? Enumerable.Empty())
- .Distinct().OrderBy(n => n, StringComparer.Ordinal).ToList();
+ // A captured metadata generation defines the public type set. Custom providers without a
+ // generation callback are enumerated each time, so a changing provider cannot remain stale.
+ private IReadOnlyList KnownTypeNames()
+ {
+ int? generation = _metadataGeneration?.Invoke();
+ lock (_typeNameGate)
+ {
+ if (generation is { } current && _typeNameCache is not null && current == _typeNameGeneration)
+ return _typeNameCache;
+ IReadOnlyList names = (_typeNamesProvider?.Invoke() ?? [])
+ .Distinct().OrderBy(name => name, StringComparer.Ordinal).ToImmutableArray();
+ if (generation is { } next)
+ {
+ _typeNameCache = names;
+ _typeNameGeneration = next;
+ }
+ return names;
+ }
+ }
private static readonly Regex DecoratorRx =
new(@"@(\w+)(?:\s*\(\s*""([^""]*)""\s*\))?", RegexOptions.Compiled);
diff --git a/src/SharpTS.LanguageServer/Services/DefinitionService.cs b/src/SharpTS.LanguageServer/Services/DefinitionService.cs
index 6f6dc793c..9b7f2b293 100644
--- a/src/SharpTS.LanguageServer/Services/DefinitionService.cs
+++ b/src/SharpTS.LanguageServer/Services/DefinitionService.cs
@@ -1,42 +1,106 @@
+using System.Diagnostics.CodeAnalysis;
using OmniSharp.Extensions.LanguageServer.Protocol.Models;
-using SharpTS.TypeSystem;
+using SharpTS.Parsing;
namespace SharpTS.LanguageServer.Services;
-///
-/// Resolves source positions to checker-bound declarations across a module graph.
-///
-///
-/// The service intentionally returns no result for names the checker did not bind. In particular,
-/// property/member navigation still needs its own complete semantic domain; guessing from matching
-/// text would produce incorrect navigation under shadowing.
-///
-public sealed class DefinitionService
+internal sealed record NavigationDefinitionResult(
+ IReadOnlyList Locations,
+ AnalysisValidation? Validation = null)
{
- ///
- /// Finds the value or type declaration selected by an LSP position.
- ///
+ public bool IsCurrent(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Validation?.IsCurrent(cancellationToken) ?? true;
+ }
+}
+
+/// Resolves lexical bindings and proven source members to their declarations.
+public sealed class DefinitionService : IDisposable
+{
+ private readonly SemanticAnalysisService _analysis;
+ private readonly bool _ownsAnalysis;
+
+ public DefinitionService(SemanticAnalysisService? analysis = null)
+ {
+ _analysis = analysis ?? new SemanticAnalysisService();
+ _ownsAnalysis = analysis is null;
+ }
+
+ [SuppressMessage("Usage", "VSTHRD002", Justification = "Compatibility wrapper for existing synchronous callers.")]
public IReadOnlyList FindDefinitions(
- string path,
- string text,
- Position position,
+ string path, string text, Position position,
IReadOnlyDictionary? openDocuments = null)
{
- if (NavigationModelBuilder.TryBuild(path, text, openDocuments) is not { } model)
- return [];
-
- int offset = model.Document.Lines.ToOffset(
- (int)position.Line + 1,
- (int)position.Character + 1);
- return model.Checker.Bindings.FindSymbols(
- model.Document,
- offset)
- .SelectMany(symbol => symbol.Declarations)
- .DistinctBy(declaration => (
- declaration.Document.Path,
- declaration.Name.Start))
- .Select(declaration =>
- NavigationLocations.From(declaration.Document, declaration.Name))
- .ToArray();
+ NavigationDefinitionResult result = FindDefinitionsLegacyAsync(path, text, position, openDocuments)
+ .GetAwaiter().GetResult();
+ return result.IsCurrent() ? result.Locations : [];
+ }
+
+ internal async Task FindDefinitionsAsync(
+ DocumentRequestSnapshot capture, Position position, CancellationToken cancellationToken)
+ {
+ if (capture.Document.FilePath is null)
+ return new NavigationDefinitionResult([]);
+ using AnalysisLease? lease = await _analysis.GetDocumentAsync(capture, cancellationToken).ConfigureAwait(false);
+ return Project(lease, position, cancellationToken);
+ }
+
+ private async Task FindDefinitionsLegacyAsync(
+ string path, string text, Position position, IReadOnlyDictionary? openDocuments)
+ {
+ using AnalysisLease? lease = await _analysis.GetDocumentAsync(path, text, openDocuments).ConfigureAwait(false);
+ return Project(lease, position, CancellationToken.None);
+ }
+
+ private static NavigationDefinitionResult Project(
+ AnalysisLease? lease, Position position, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (lease is null) return new NavigationDefinitionResult([]);
+ CheckedNavigationModel model = lease.Model;
+ int offset = model.Document.Lines.ToOffset((int)position.Line + 1, (int)position.Character + 1);
+ var targets = new List<(string Path, SourceDocument Document, Token Name)>();
+ var seen = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ var symbols = model.Bindings.FindSymbols(model.Document, offset);
+ if (symbols.Count != 0)
+ {
+ // A lexical binding remains authoritative even if it has no navigable declaration.
+ // In particular, import/namespace bindings must not fall through to a same-spelled member.
+ foreach (var symbol in symbols)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ foreach (var declaration in symbol.Declarations)
+ AddTarget(declaration.Document, declaration.Name);
+ }
+ }
+ else
+ {
+ foreach (var declaration in model.Members.FindDefinitions(model.Document, offset))
+ AddTarget(declaration.Document, declaration.Name);
+ }
+
+ var locations = new List(targets.Count);
+ foreach (var target in targets.OrderBy(target => target.Path, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(target => target.Path, StringComparer.Ordinal)
+ .ThenBy(target => target.Name.Start).ThenBy(target => target.Name.End))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ locations.Add(NavigationLocations.From(target.Document, target.Name));
+ }
+ return new NavigationDefinitionResult(locations.ToArray(), lease.Validation);
+
+ void AddTarget(SourceDocument document, Token name)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ string path = Path.IsPathFullyQualified(document.Path) ? Path.GetFullPath(document.Path) : document.Path;
+ if (!seen.TryGetValue(path, out var spans)) seen.Add(path, spans = []);
+ if (spans.Add(name.Span)) targets.Add((path, document, name));
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_ownsAnalysis) _analysis.Dispose();
}
}
diff --git a/src/SharpTS.LanguageServer/Services/DiagnosticsCoordinator.cs b/src/SharpTS.LanguageServer/Services/DiagnosticsCoordinator.cs
index f81ac57c3..a7b358cd4 100644
--- a/src/SharpTS.LanguageServer/Services/DiagnosticsCoordinator.cs
+++ b/src/SharpTS.LanguageServer/Services/DiagnosticsCoordinator.cs
@@ -157,9 +157,10 @@ private async Task RunChangedDocumentAsync(
return;
cancellationToken.ThrowIfCancellationRequested();
- IReadOnlyList statements = _diagnostics.GetStatements(
- snapshot.Document,
- cancellationToken);
+ IReadOnlyList statements = await _diagnostics.GetStatementsAsync(
+ snapshot,
+ _settings.Mode,
+ cancellationToken).ConfigureAwait(false);
IReadOnlySet affected = _graph.Update(
snapshot.Document,
snapshot.TextOverlay,
@@ -184,11 +185,11 @@ private async Task RunAffectedDocumentsAsync(
if (!_store.TryCapture(open.Uri, out DocumentRequestSnapshot? snapshot))
continue;
- Publish(snapshot, snapshot.Document, cancellationToken);
+ await PublishAsync(snapshot, snapshot.Document, cancellationToken).ConfigureAwait(false);
}
}
- private Task AnalyzeAndPublishAsync(
+ private async Task AnalyzeAndPublishAsync(
DocumentRequestSnapshot workspace,
IReadOnlySet affectedPaths,
CancellationToken cancellationToken)
@@ -202,23 +203,25 @@ private Task AnalyzeAndPublishAsync(
continue;
}
- Publish(workspace, document, cancellationToken);
+ await PublishAsync(workspace, document, cancellationToken).ConfigureAwait(false);
}
- return Task.CompletedTask;
}
- private void Publish(
+ private async Task PublishAsync(
DocumentRequestSnapshot workspace,
DocumentSnapshot document,
CancellationToken cancellationToken)
{
- var diagnostics = _diagnostics.Analyze(
+ DiagnosticsAnalysisResult result = await _diagnostics.AnalyzeResultAsync(
workspace,
document,
_settings.Mode,
- cancellationToken);
+ cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
+ if (!result.IsCurrent(cancellationToken))
+ return;
+
if (!_store.IsCurrent(
document.Uri,
document.Version,
@@ -227,11 +230,15 @@ private void Publish(
return;
}
+ if (!result.IsCurrent(cancellationToken))
+ return;
+ cancellationToken.ThrowIfCancellationRequested();
+
_publish(new PublishDiagnosticsParams
{
Uri = DocumentUri.Parse(document.Uri),
Version = document.Version,
- Diagnostics = new Container(diagnostics),
+ Diagnostics = new Container(result.Diagnostics),
});
}
diff --git a/src/SharpTS.LanguageServer/Services/DiagnosticsService.cs b/src/SharpTS.LanguageServer/Services/DiagnosticsService.cs
index 5d03ba3ad..378c78b5a 100644
--- a/src/SharpTS.LanguageServer/Services/DiagnosticsService.cs
+++ b/src/SharpTS.LanguageServer/Services/DiagnosticsService.cs
@@ -1,6 +1,9 @@
using System.Collections.Concurrent;
+using System.Diagnostics.CodeAnalysis;
using SharpTS.Diagnostics;
+using SharpTS.IO;
using SharpTS.LanguageServer.Conversions;
+using SharpTS.LanguageServer.Project;
using SharpTS.Parsing;
using SharpTS.TypeSystem;
using LspDiagnostic = OmniSharp.Extensions.LanguageServer.Protocol.Models.Diagnostic;
@@ -8,25 +11,59 @@
namespace SharpTS.LanguageServer.Services;
+/// Diagnostics and the input validation required before they can be published.
+internal sealed record DiagnosticsAnalysisResult(
+ List Diagnostics,
+ AnalysisValidation? Validation = null,
+ AnalysisMetadataProvider? Metadata = null,
+ int? MetadataGeneration = null)
+{
+ public bool IsCurrent(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (Validation is not null && !Validation.IsCurrent(cancellationToken))
+ return false;
+ if (Metadata is null || MetadataGeneration is null)
+ return true;
+ // Validation must see physical inputs, even when called inside an older build scope.
+ using var scope = CompilerFileSystem.Use(CompilerFileSystem.Physical, cancellationToken);
+ return Metadata.RefreshGeneration() == MetadataGeneration;
+ }
+}
+
///
-/// Produces the LSP diagnostics for a document. Phase 1 = the @DotNetType interop analyzer
-/// (the tsserver-impossible value). Parse errors are left to the built-in TypeScript server.
+/// Uses shared checked editor snapshots for full diagnostics and keeps SharpTS-only analysis
+/// lazy so an interop-only editor never starts the general TypeScript checker.
///
-public sealed class DiagnosticsService
+public sealed class DiagnosticsService : IDisposable
{
private readonly InteropAnalyzer _interop;
+ private readonly SemanticAnalysisService _analysis;
+ private readonly bool _ownsAnalysis;
+ private readonly AnalysisMetadataProvider? _metadata;
private readonly ConcurrentDictionary _cache =
new(StringComparer.OrdinalIgnoreCase);
- private readonly ConcurrentDictionary _workspaceChecks =
- new(StringComparer.OrdinalIgnoreCase);
- /// CLR type resolver. Null = in-process registry (BCL only);
- /// the server injects an AssemblyReferenceLoader when a project/references are configured
- /// so the user's own @DotNetType targets resolve too.
+ /// CLR resolver, or the in-process registry when none is supplied.
+ /// Public CLR type names used for interop suggestions.
+ /// The server's shared checked analysis service.
+ /// Optional stable CLR metadata generations for interop diagnostics.
public DiagnosticsService(
Func? resolve = null,
- Func>? typeNames = null) =>
+ Func>? typeNames = null,
+ SemanticAnalysisService? analysis = null,
+ AnalysisMetadataProvider? metadata = null)
+ {
+ _analysis = analysis ?? new SemanticAnalysisService();
+ _ownsAnalysis = analysis is null;
+ _metadata = metadata;
+ if (metadata is not null)
+ {
+ resolve ??= metadata.Resolve;
+ typeNames ??= metadata.GetTypeNames;
+ }
_interop = new InteropAnalyzer(resolve, typeNames);
+ }
public List Analyze(
string text,
@@ -34,232 +71,185 @@ public List Analyze(
string? fileName = null)
{
var snapshot = new DocumentSnapshot(
- fileName is null
- ? "untitled:diagnostics"
- : new Uri(Path.GetFullPath(fileName)).AbsoluteUri,
+ fileName is null ? "untitled:diagnostics" : new Uri(Path.GetFullPath(fileName)).AbsoluteUri,
text,
Version: 0,
fileName is null ? null : Path.GetFullPath(fileName));
return Analyze(snapshot, mode, CancellationToken.None);
}
- ///
- /// Analyzes one immutable text version. Lexing, AST/source spans, interop diagnostics, and
- /// the optional full type-check result are retained together and reused only for that exact
- /// version and text.
- ///
public List Analyze(
DocumentSnapshot snapshot,
DiagnosticPublishMode mode,
CancellationToken cancellationToken)
+ {
+ IReadOnlyDictionary documents = snapshot.FilePath is null
+ ? new Dictionary()
+ : new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [snapshot.FilePath] = snapshot,
+ };
+ return Analyze(new DocumentRequestSnapshot(snapshot, 0, documents), snapshot, mode, cancellationToken);
+ }
+
+ [SuppressMessage("Usage", "VSTHRD002", Justification = "Compatibility wrapper; production publication awaits analysis.")]
+ public List Analyze(
+ DocumentRequestSnapshot workspace,
+ DocumentSnapshot snapshot,
+ DiagnosticPublishMode mode,
+ CancellationToken cancellationToken)
+ {
+ DiagnosticsAnalysisResult result = AnalyzeResultAsync(workspace, snapshot, mode, cancellationToken)
+ .GetAwaiter().GetResult();
+ return result.IsCurrent(cancellationToken) ? result.Diagnostics : [];
+ }
+
+ internal async Task AnalyzeResultAsync(
+ DocumentRequestSnapshot workspace,
+ DocumentSnapshot snapshot,
+ DiagnosticPublishMode mode,
+ CancellationToken cancellationToken)
{
if (mode == DiagnosticPublishMode.Off)
- return [];
+ return new DiagnosticsAnalysisResult([]);
- CachedAnalysis analysis = GetOrBuild(snapshot, cancellationToken);
- cancellationToken.ThrowIfCancellationRequested();
+ if (mode == DiagnosticPublishMode.All && snapshot.FilePath is not null)
+ {
+ using AnalysisLease? lease = await _analysis.GetDocumentAsync(
+ workspace with { Document = snapshot }, cancellationToken).ConfigureAwait(false);
+ if (lease is not null && TryGetSharedDocument(lease, snapshot, out AnalysisDocument? document))
+ {
+ using var metadataScope = lease.EnterMetadataScope();
+ var diagnostics = new List();
+ if (!document!.HasRecoveredSyntax)
+ {
+ diagnostics.AddRange(_interop.Analyze(
+ document.Statements, new PositionMap(snapshot.Text), cancellationToken));
+ }
+ diagnostics.AddRange(document.ParseDiagnostics);
+ diagnostics.AddRange(lease.Model.Snapshot.Diagnostics.Where(diagnostic =>
+ BelongsToDocument(diagnostic, snapshot.FilePath)));
+ cancellationToken.ThrowIfCancellationRequested();
+ return new DiagnosticsAnalysisResult(
+ LspConversions.ToLsp(diagnostics, snapshot.Text, mode), lease.Validation);
+ }
+ }
- var diagnostics = new List(analysis.InteropDiagnostics);
+ // Untitled or unavailable semantic models retain standalone diagnostics. These ASTs
+ // are private to this cheap cache and are never a published semantic snapshot's AST.
+ using AnalysisMetadataView? metadata = _metadata?.Capture();
+ using var scope = metadata?.EnterScope();
+ CachedAnalysis analysis = GetOrBuild(snapshot, metadata?.Generation ?? 0, cancellationToken);
+ var standaloneDiagnostics = new List(analysis.InteropDiagnostics);
if (mode == DiagnosticPublishMode.All)
{
- diagnostics.AddRange(analysis.ParseResult.Diagnostics);
+ standaloneDiagnostics.AddRange(analysis.ParseResult.Diagnostics);
if (analysis.ParseResult.IsSuccess)
{
- TypeCheckDiagnosticResult result =
- analysis.TypeCheckResult ??
- BuildTypeCheck(analysis, cancellationToken);
- analysis.TypeCheckResult = result;
- diagnostics.AddRange(result.Diagnostics);
+ lock (analysis.TypeCheckGate)
+ {
+ analysis.TypeCheckResult ??= BuildTypeCheck(analysis, cancellationToken);
+ standaloneDiagnostics.AddRange(analysis.TypeCheckResult.Diagnostics);
+ }
}
}
+ cancellationToken.ThrowIfCancellationRequested();
+ return new DiagnosticsAnalysisResult(
+ LspConversions.ToLsp(standaloneDiagnostics, snapshot.Text, mode),
+ Metadata: _metadata, MetadataGeneration: metadata?.Generation);
+ }
- return LspConversions.ToLsp(diagnostics, snapshot.Text, mode);
+ public void Invalidate(string uriOrPath) => _cache.TryRemove(uriOrPath, out _);
+
+ internal IReadOnlyList GetStatements(DocumentSnapshot snapshot, CancellationToken cancellationToken)
+ {
+ using AnalysisMetadataView? metadata = _metadata?.Capture();
+ using var scope = metadata?.EnterScope();
+ return GetOrBuild(snapshot, metadata?.Generation ?? 0, cancellationToken).ParseResult.Statements;
}
- ///
- /// Workspace-aware form used by diagnostic publication. In all mode it checks the
- /// module graph from the same overlay snapshot and caches that result by workspace version.
- ///
- public List Analyze(
+ internal async Task> GetStatementsAsync(
DocumentRequestSnapshot workspace,
- DocumentSnapshot snapshot,
DiagnosticPublishMode mode,
CancellationToken cancellationToken)
{
- if (mode != DiagnosticPublishMode.All ||
- snapshot.FilePath is null)
+ if (mode == DiagnosticPublishMode.All && workspace.Document.FilePath is not null)
{
- return Analyze(snapshot, mode, cancellationToken);
+ using AnalysisLease? lease = await _analysis.GetDocumentAsync(workspace, cancellationToken)
+ .ConfigureAwait(false);
+ if (lease is not null && TryGetSharedDocument(lease, workspace.Document, out AnalysisDocument? document))
+ return document!.Statements;
}
-
- CachedAnalysis analysis = GetOrBuild(snapshot, cancellationToken);
- var diagnostics = new List(
- analysis.InteropDiagnostics);
- diagnostics.AddRange(analysis.ParseResult.Diagnostics);
- if (analysis.ParseResult.IsSuccess)
- {
- string path = snapshot.FilePath;
- CachedWorkspaceCheck workspaceCheck = _workspaceChecks.AddOrUpdate(
- path,
- _ => BuildWorkspaceCheck(
- workspace,
- snapshot,
- analysis,
- cancellationToken),
- (_, current) =>
- current.DocumentVersion == snapshot.Version &&
- current.WorkspaceVersion == workspace.WorkspaceVersion
- ? current
- : BuildWorkspaceCheck(
- workspace,
- snapshot,
- analysis,
- cancellationToken));
- diagnostics.AddRange(workspaceCheck.Diagnostics);
- }
-
- cancellationToken.ThrowIfCancellationRequested();
- return LspConversions.ToLsp(
- diagnostics,
- snapshot.Text,
- DiagnosticPublishMode.All);
+ return GetStatements(workspace.Document, cancellationToken);
}
- public void Invalidate(string uriOrPath)
+ private static bool TryGetSharedDocument(
+ AnalysisLease lease, DocumentSnapshot snapshot, out AnalysisDocument? document)
{
- _cache.TryRemove(uriOrPath, out _);
- _workspaceChecks.TryRemove(uriOrPath, out _);
+ document = null;
+ return snapshot.FilePath is not null &&
+ lease.Model.Snapshot.TryGetDocument(snapshot.FilePath, out document) &&
+ document is not null && string.Equals(document.Document.Text, snapshot.Text, StringComparison.Ordinal);
}
- internal IReadOnlyList GetStatements(
- DocumentSnapshot snapshot,
- CancellationToken cancellationToken) =>
- GetOrBuild(snapshot, cancellationToken).ParseResult.Statements;
+ private static bool BelongsToDocument(SharpDiagnostic diagnostic, string path) =>
+ diagnostic.FilePath is null || string.Equals(
+ Path.GetFullPath(diagnostic.FilePath), Path.GetFullPath(path), StringComparison.OrdinalIgnoreCase);
- private CachedAnalysis GetOrBuild(
- DocumentSnapshot snapshot,
- CancellationToken cancellationToken)
+ private CachedAnalysis GetOrBuild(DocumentSnapshot snapshot, int metadataGeneration, CancellationToken cancellationToken)
{
+ cancellationToken.ThrowIfCancellationRequested();
string key = snapshot.FilePath ?? snapshot.Uri;
- return _cache.AddOrUpdate(
- key,
- _ => Build(snapshot, cancellationToken),
- (_, current) =>
- current.Version == snapshot.Version &&
+ return _cache.AddOrUpdate(key,
+ _ => Build(snapshot, metadataGeneration, cancellationToken),
+ (_, current) => current.Version == snapshot.Version &&
+ current.MetadataGeneration == metadataGeneration &&
string.Equals(current.Text, snapshot.Text, StringComparison.Ordinal)
- ? current
- : Build(snapshot, cancellationToken));
+ ? current : Build(snapshot, metadataGeneration, cancellationToken));
}
- private CachedAnalysis Build(
- DocumentSnapshot snapshot,
- CancellationToken cancellationToken)
+ private CachedAnalysis Build(DocumentSnapshot snapshot, int metadataGeneration, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
string fileName = snapshot.FilePath ?? snapshot.Uri;
-
- // Stage3 decorators are the run-mode default and are required for @DotNetType to parse.
- bool isTsx =
- (fileName.EndsWith(".tsx", StringComparison.OrdinalIgnoreCase) ||
- fileName.EndsWith(".jsx", StringComparison.OrdinalIgnoreCase));
- List tokens =
- new Lexer(snapshot.Text) { JsxTolerant = isTsx }.ScanTokens();
- cancellationToken.ThrowIfCancellationRequested();
-
- var document = new SourceDocument(
- fileName,
- snapshot.Text,
- isVirtual: snapshot.FilePath is null);
+ bool isTsx = fileName.EndsWith(".tsx", StringComparison.OrdinalIgnoreCase) ||
+ fileName.EndsWith(".jsx", StringComparison.OrdinalIgnoreCase);
+ List tokens = new Lexer(snapshot.Text) { JsxTolerant = isTsx }
+ .WithCancellation(cancellationToken).ScanTokens();
+ var document = new SourceDocument(fileName, snapshot.Text, isVirtual: snapshot.FilePath is null);
var parser = new Parser(tokens, DecoratorMode.Stage3)
- .WithSourceDocument(document);
- if (isTsx)
- {
- parser.WithJsx(snapshot.Text, JsxParseOptions.Default);
- }
+ .WithCancellation(cancellationToken).WithSourceDocument(document);
+ if (isTsx) parser.WithJsx(snapshot.Text, JsxParseOptions.Default);
ParseDiagnosticResult parsed = parser.Parse();
cancellationToken.ThrowIfCancellationRequested();
-
IReadOnlyList interopDiagnostics = parsed.IsSuccess
- ? _interop.Analyze(
- parsed.Statements,
- new PositionMap(snapshot.Text),
- cancellationToken)
- : [];
-
- return new CachedAnalysis(
- snapshot.Version,
- snapshot.Text,
- document,
- tokens,
- parsed,
- interopDiagnostics);
+ ? _interop.Analyze(parsed.Statements, new PositionMap(snapshot.Text), cancellationToken) : [];
+ return new CachedAnalysis(snapshot.Version, metadataGeneration, snapshot.Text, document, parsed, interopDiagnostics);
}
- private static TypeCheckDiagnosticResult BuildTypeCheck(
- CachedAnalysis analysis,
- CancellationToken cancellationToken)
+ private static TypeCheckDiagnosticResult BuildTypeCheck(CachedAnalysis analysis, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
- var checker = new TypeChecker(
- TypeCheckerOptions.Default with { MaxErrors = int.MaxValue })
- .WithFilePath(analysis.Document.Path)
- .WithCancellation(cancellationToken);
- return checker.CheckWithRecovery(
- analysis.ParseResult.Statements,
- analysis.Document);
+ var checker = new TypeChecker(TypeCheckerOptions.Default with { MaxErrors = int.MaxValue })
+ .WithFilePath(analysis.Document.Path).WithCancellation(cancellationToken);
+ return checker.CheckWithRecovery(analysis.ParseResult.Statements, analysis.Document);
}
- private static CachedWorkspaceCheck BuildWorkspaceCheck(
- DocumentRequestSnapshot workspace,
- DocumentSnapshot snapshot,
- CachedAnalysis analysis,
- CancellationToken cancellationToken)
+ public void Dispose()
{
- cancellationToken.ThrowIfCancellationRequested();
- CheckedNavigationModel? model = NavigationModelBuilder.TryBuild(
- snapshot.FilePath!,
- snapshot.Text,
- workspace.TextOverlay,
- cancellationToken);
- IReadOnlyList diagnostics;
- if (model is null)
- {
- diagnostics = BuildTypeCheck(
- analysis,
- cancellationToken)
- .Diagnostics;
- }
- else
- {
- diagnostics = model.Checker.GetDiagnostics()
- .Where(diagnostic =>
- diagnostic.FilePath is null ||
- string.Equals(
- Path.GetFullPath(diagnostic.FilePath),
- snapshot.FilePath,
- StringComparison.OrdinalIgnoreCase))
- .ToArray();
- }
-
- return new CachedWorkspaceCheck(
- snapshot.Version,
- workspace.WorkspaceVersion,
- diagnostics);
+ _cache.Clear();
+ if (_ownsAnalysis) _analysis.Dispose();
}
- internal sealed record CachedAnalysis(
+ private sealed record CachedAnalysis(
int Version,
+ int MetadataGeneration,
string Text,
SourceDocument Document,
- IReadOnlyList Tokens,
ParseDiagnosticResult ParseResult,
IReadOnlyList InteropDiagnostics)
{
+ public object TypeCheckGate { get; } = new();
public TypeCheckDiagnosticResult? TypeCheckResult { get; set; }
}
-
- private sealed record CachedWorkspaceCheck(
- int DocumentVersion,
- long WorkspaceVersion,
- IReadOnlyList Diagnostics);
}
diff --git a/src/SharpTS.LanguageServer/Services/EditorRequestGuard.cs b/src/SharpTS.LanguageServer/Services/EditorRequestGuard.cs
new file mode 100644
index 000000000..a0c271732
--- /dev/null
+++ b/src/SharpTS.LanguageServer/Services/EditorRequestGuard.cs
@@ -0,0 +1,45 @@
+using SharpTS.IO;
+using SharpTS.LanguageServer.Project;
+
+namespace SharpTS.LanguageServer.Services;
+
+/// Captures interop/GUI inputs without starting general semantic analysis.
+internal sealed class EditorRequestGuard : IDisposable
+{
+ private readonly ObservingCompilerFileSystem _inputs = new();
+ private readonly IDisposable _fileScope;
+ private readonly AnalysisMetadataProvider? _metadata;
+ private readonly AnalysisMetadataView? _view;
+ private readonly IDisposable? _metadataScope;
+ private readonly DocumentStore _store;
+ private readonly DocumentRequestSnapshot _request;
+
+ public EditorRequestGuard(DocumentStore store, DocumentRequestSnapshot request,
+ AnalysisMetadataProvider? metadata, CancellationToken cancellationToken)
+ {
+ _store = store; _request = request; _metadata = metadata;
+ _fileScope = CompilerFileSystem.Use(_inputs, cancellationToken);
+ try
+ {
+ _view = metadata?.Capture();
+ _metadataScope = _view?.EnterScope();
+ }
+ catch { _view?.Dispose(); _fileScope.Dispose(); throw; }
+ }
+
+ public bool IsCurrent(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!_inputs.Complete().IsCurrent(cancellationToken)) return false;
+ using var physicalScope = CompilerFileSystem.Use(CompilerFileSystem.Physical, cancellationToken);
+ if (_metadata is not null && _metadata.RefreshGeneration() != _view!.Generation) return false;
+ return _store.IsCurrent(_request.Document.Uri, _request.Document.Version, _request.WorkspaceVersion);
+ }
+
+ public void Dispose()
+ {
+ _metadataScope?.Dispose();
+ _view?.Dispose();
+ _fileScope.Dispose();
+ }
+}
diff --git a/src/SharpTS.LanguageServer/Services/EditorTokenFacts.cs b/src/SharpTS.LanguageServer/Services/EditorTokenFacts.cs
new file mode 100644
index 000000000..766466e8c
--- /dev/null
+++ b/src/SharpTS.LanguageServer/Services/EditorTokenFacts.cs
@@ -0,0 +1,17 @@
+using SharpTS.Parsing;
+
+namespace SharpTS.LanguageServer.Services;
+
+internal static class EditorTokenFacts
+{
+ // Mirrors the parser's contextual identifier grammar for syntax admission only.
+ // The fresh parse/check remains the authority for receiver and invocation facts.
+ internal static bool IsContextualIdentifier(TokenType type) => type is
+ TokenType.TYPE or TokenType.MODULE or TokenType.NAMESPACE or TokenType.ASYNC or TokenType.DECLARE or
+ TokenType.ABSTRACT or TokenType.READONLY or TokenType.OVERRIDE or TokenType.GLOBAL or TokenType.OF or
+ TokenType.FROM or TokenType.SATISFIES or TokenType.ACCESSOR or TokenType.OUT or TokenType.UNIQUE or
+ TokenType.UNKNOWN or TokenType.NEVER or TokenType.INFER or TokenType.KEYOF or TokenType.ASSERTS or
+ TokenType.IS or TokenType.TYPE_STRING or TokenType.TYPE_NUMBER or TokenType.TYPE_BOOLEAN or
+ TokenType.TYPE_SYMBOL or TokenType.TYPE_BIGINT or TokenType.GET or TokenType.SET or TokenType.UNDEFINED or
+ TokenType.CONSTRUCTOR or TokenType.SYMBOL or TokenType.BIGINT;
+}
diff --git a/src/SharpTS.LanguageServer/Services/GuiContractService.cs b/src/SharpTS.LanguageServer/Services/GuiContractService.cs
index 8e99206c3..b58d15214 100644
--- a/src/SharpTS.LanguageServer/Services/GuiContractService.cs
+++ b/src/SharpTS.LanguageServer/Services/GuiContractService.cs
@@ -2,6 +2,7 @@
using System.Text.RegularExpressions;
using OmniSharp.Extensions.LanguageServer.Protocol;
using OmniSharp.Extensions.LanguageServer.Protocol.Models;
+using SharpTS.IO;
namespace SharpTS.LanguageServer.Services;
@@ -80,14 +81,21 @@ private sealed record Contract(string DeclarationPath, Control[] Controls);
}
public Location? Definition(string? documentPath, string text, int line, int character)
+ {
+ try { return FindDefinition(documentPath, text, line, character); }
+ catch (IOException) { return null; }
+ catch (UnauthorizedAccessException) { return null; }
+ }
+
+ private static Location? FindDefinition(string? documentPath, string text, int line, int character)
{
Contract? contract = Load(documentPath);
string? lineText = GetLine(text, line);
- if (contract is null || lineText is null || !File.Exists(contract.DeclarationPath)) return null;
+ if (contract is null || lineText is null || !CompilerFileSystem.FileExists(contract.DeclarationPath)) return null;
(string word, _, _) = WordAt(lineText, character);
Control? control = contract.Controls.FirstOrDefault(item => item.Kind == word);
if (control is null) return null;
- string[] declarations = File.ReadAllLines(contract.DeclarationPath);
+ string[] declarations = CompilerFileSystem.ReadAllText(contract.DeclarationPath).Split('\n');
int declarationLine = Array.FindIndex(declarations, value => value.Contains($"export const {word} =", StringComparison.Ordinal));
if (declarationLine < 0) return null;
int start = declarations[declarationLine].IndexOf(word, StringComparison.Ordinal);
@@ -101,11 +109,11 @@ private sealed record Contract(string DeclarationPath, Control[] Controls);
private static Contract? Load(string? documentPath)
{
- string? metadata = LocateMetadata(documentPath);
- if (metadata is null) return null;
try
{
- using JsonDocument json = JsonDocument.Parse(File.ReadAllText(metadata));
+ string? metadata = LocateMetadata(documentPath);
+ if (metadata is null) return null;
+ using JsonDocument json = JsonDocument.Parse(CompilerFileSystem.ReadAllText(metadata));
var controls = json.RootElement.GetProperty("controls").EnumerateArray().Select(control => new Control(
control.GetProperty("kind").GetString()!,
control.GetProperty("documentation").GetString() ?? string.Empty,
@@ -119,7 +127,10 @@ private sealed record Contract(string DeclarationPath, Control[] Controls);
return new Contract(Path.Combine(Path.GetDirectoryName(metadata)!, "control-surface.generated.ts"), controls);
}
catch (IOException) { return null; }
+ catch (UnauthorizedAccessException) { return null; }
catch (JsonException) { return null; }
+ catch (KeyNotFoundException) { return null; }
+ catch (InvalidOperationException) { return null; }
}
private static string? LocateMetadata(string? documentPath)
@@ -128,41 +139,54 @@ private sealed record Contract(string DeclarationPath, Control[] Controls);
string? directory = Path.GetDirectoryName(Path.GetFullPath(documentPath));
while (directory is not null)
{
- string[] projects = Directory.Exists(directory)
- ? Directory.GetFiles(directory, "*.csproj", SearchOption.TopDirectoryOnly)
+ CompilerFileSystem.ThrowIfCancellationRequested();
+ IReadOnlyList projects = CompilerFileSystem.DirectoryExists(directory)
+ ? CompilerFileSystem.EnumerateFiles(directory, "*.csproj", SearchOption.TopDirectoryOnly)
: [];
- string? guiProject = projects.FirstOrDefault(path => File.ReadAllText(path).Contains("SharpTS.Gui.Sdk", StringComparison.OrdinalIgnoreCase));
+ string? guiProject = projects.FirstOrDefault(path => CompilerFileSystem.ReadAllText(path).Contains("SharpTS.Gui.Sdk", StringComparison.OrdinalIgnoreCase));
if (guiProject is not null)
{
string obj = Path.Combine(directory, "obj");
- if (Directory.Exists(obj))
+ if (CompilerFileSystem.DirectoryExists(obj))
{
- string? built = Directory.EnumerateFiles(obj, "control-docs.generated.json", SearchOption.AllDirectories)
+ string? built = CompilerFileSystem.EnumerateFiles(obj, "control-docs.generated.json", new EnumerationOptions
+ { RecurseSubdirectories = true, IgnoreInaccessible = true })
.FirstOrDefault(path => path.Contains("@sharpts", StringComparison.OrdinalIgnoreCase));
if (built is not null) return built;
}
- Match version = Regex.Match(File.ReadAllText(guiProject), @"SharpTS\.Gui\.Sdk/(?[^\""<]+)");
+ Match version = Regex.Match(CompilerFileSystem.ReadAllText(guiProject), @"SharpTS\.Gui\.Sdk/(?[^\""<]+)");
if (version.Success)
{
string packages = Environment.GetEnvironmentVariable("NUGET_PACKAGES")
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages");
string cached = Path.Combine(packages, "sharpts.gui.sdk", version.Groups["version"].Value, "gui", "control-docs.generated.json");
- if (File.Exists(cached)) return cached;
+ if (CompilerFileSystem.FileExists(cached)) return cached;
}
string? repository = directory;
while (repository is not null)
{
string source = Path.Combine(repository, "src", "SharpTS.Gui.Sdk", "GuiPackage", "control-docs.generated.json");
- if (File.Exists(source)) return source;
- repository = Path.GetDirectoryName(repository);
+ if (CompilerFileSystem.FileExists(source)) return source;
+ repository = AmbientParent(repository);
}
return null;
}
- directory = Path.GetDirectoryName(directory);
+ directory = AmbientParent(directory);
}
return null;
}
+ private static string? AmbientParent(string directory)
+ {
+ string? parent = Path.GetDirectoryName(directory);
+ if (parent is null) return null;
+ string normalized = Path.TrimEndingDirectorySeparator(Path.GetFullPath(parent));
+ return new[] { Path.GetTempPath(), Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) }
+ .Where(path => !string.IsNullOrWhiteSpace(path))
+ .Any(path => string.Equals(normalized, Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)),
+ StringComparison.OrdinalIgnoreCase)) ? null : parent;
+ }
+
private static Hover Markdown(string value) => new()
{
Contents = new MarkedStringsOrMarkupContent(new MarkupContent { Kind = MarkupKind.Markdown, Value = value }),
diff --git a/src/SharpTS.LanguageServer/Services/MemberHoverService.cs b/src/SharpTS.LanguageServer/Services/MemberHoverService.cs
index 7d68a7183..811d93c17 100644
--- a/src/SharpTS.LanguageServer/Services/MemberHoverService.cs
+++ b/src/SharpTS.LanguageServer/Services/MemberHoverService.cs
@@ -30,16 +30,88 @@ public sealed class MemberHoverService
public MemberHoverService(Func? resolve = null)
=> _resolve = resolve ?? DotNetTypeRegistry.Resolve;
- public Hover? Hover(string text, int line, int character)
+ public Hover? Hover(string text, int line, int character, CancellationToken cancellationToken = default)
{
- var tokens = new Lexer(text).ScanTokens();
- var parsed = new Parser(tokens, DecoratorMode.Stage3).Parse();
- if (!parsed.IsSuccess) return null;
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!MayHaveBindings(text)) return null;
+ var statements = ParseInterop(text, cancellationToken);
+ if (statements is null) return null;
var pos = new PositionMap(text);
- var bindings = CollectBindings(parsed.Statements);
- return DeclarationHover(parsed.Statements, pos, line, character)
- ?? UsageHover(parsed.Statements, pos, line, character, bindings);
+ var bindings = CollectBindings(statements);
+ return DeclarationHover(statements, pos, line, character)
+ ?? (bindings.Count == 0 ? null : UsageHover(statements, pos, line, character, bindings, cancellationToken));
+ }
+
+ /// The existing CLR declaration priority needs parsing, but no semantic check.
+ internal Hover? DeclarationHover(string text, int line, int character, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!text.Contains("DotNetType", StringComparison.Ordinal)) return null;
+ var statements = ParseInterop(text, cancellationToken);
+ return statements is null ? null : DeclarationHover(statements, new PositionMap(text), line, character);
+ }
+
+ private static List? ParseInterop(string text, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var tokens = new Lexer(text).WithCancellation(cancellationToken).ScanTokens();
+ var parsed = new Parser(tokens, DecoratorMode.Stage3).WithCancellation(cancellationToken).Parse();
+ return parsed.IsSuccess ? parsed.Statements : null;
+ }
+ catch (OperationCanceledException) { throw; }
+ catch { return null; }
+ }
+
+ /// Full-mode usage hover reuses the captured AST and a final successful receiver.
+ internal Hover? Hover(CheckedNavigationModel model, int offset, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (model.Document.EditorSyntax?.FindMember(offset) is not { IsRecovered: false, IsIndex: false } member ||
+ !member.Name.Span.Contains(offset)) return null;
+ var occurrence = model.EditorFacts.GetOccurrence(model.Document, member.Receiver);
+ if (occurrence is not { Availability: EditorFactAvailability.Available, Type.IsAvailable: true }) return null;
+ TypeInfo? type = model.Snapshot.GetType(member.Receiver);
+ Type? clr = null;
+ if (type is TypeInfo.ExternalDotNetType external)
+ clr = ResolveExternal(external, new Dictionary());
+ else if (type is not null && DotNetTypeSynthesizer.TryGetClrType(type, out var imported))
+ clr = imported;
+ else if (type is TypeInfo.Class @class && DotNetTypeSynthesizer.TryGetClrType(new TypeInfo.Instance(@class), out var staticImported))
+ clr = staticImported;
+ else if (SourceClassId(type) is var classId && classId != 0 &&
+ model.Members.GetClassInfo(classId)?.Owner is Stmt.Class declaration)
+ clr = DotNetTypeOf(declaration);
+ if (clr is null) return null;
+ cancellationToken.ThrowIfCancellationRequested();
+ var hover = Render(RenderMember(clr, member.Name.Lexeme));
+ if (hover is null) return null;
+ var (startLine, startColumn) = model.Document.Lines.ToPosition(member.Name.Start);
+ var (endLine, endColumn) = model.Document.Lines.ToPosition(member.Name.End);
+ return new Hover
+ {
+ Contents = hover.Contents,
+ Range = new OmniSharp.Extensions.LanguageServer.Protocol.Models.Range(
+ startLine - 1, startColumn - 1, endLine - 1, endColumn - 1),
+ };
+ }
+
+ private static bool MayHaveBindings(string text) => text.Contains("DotNetType", StringComparison.Ordinal) ||
+ text.Contains("dotnet:", StringComparison.Ordinal);
+
+ private static int SourceClassId(TypeInfo? type)
+ {
+ for (int depth = 0; depth < 32; depth++)
+ switch (type)
+ {
+ case TypeInfo.Instance instance: type = instance.ResolvedClassType; break;
+ case TypeInfo.InstantiatedGeneric instantiated: type = instantiated.GenericDefinition; break;
+ case TypeInfo.Class @class: return @class.Core.DeclarationId;
+ case TypeInfo.GenericClass generic: return generic.Core.DeclarationId;
+ default: return 0;
+ }
+ return 0;
}
// TS class name -> CLR type, for every @DotNetType declaration and dotnet: import
@@ -68,6 +140,7 @@ private Dictionary CollectBindings(IReadOnlyList statements)
map[spec.LocalName?.Lexeme ?? spec.Imported.Lexeme] = type;
map.TryAdd(type.Name, type);
}
+ catch (OperationCanceledException) { throw; }
catch
{
// Unresolvable import — no hover; InteropAnalyzer reports the diagnostic.
@@ -98,7 +171,8 @@ private Dictionary CollectBindings(IReadOnlyList statements)
}
// --- Usage: cursor on a member-access name; resolve the receiver via the TypeMap ---
- private Hover? UsageHover(List statements, PositionMap pos, int line, int ch, IReadOnlyDictionary bindings)
+ private Hover? UsageHover(List statements, PositionMap pos, int line, int ch, IReadOnlyDictionary bindings,
+ CancellationToken cancellationToken)
{
var finder = new GetFinder(pos, line, ch);
foreach (var stmt in statements) finder.Visit(stmt);
@@ -107,10 +181,11 @@ private Dictionary CollectBindings(IReadOnlyList statements)
TypeMap typeMap;
try
{
- var checker = new TypeChecker();
+ var checker = new TypeChecker().WithCancellation(cancellationToken);
checker.SetDecoratorMode(DecoratorMode.Stage3);
typeMap = checker.CheckWithRecovery(statements).TypeMap;
}
+ catch (OperationCanceledException) { throw; }
catch
{
return null; // a half-typed buffer shouldn't surface hover errors
diff --git a/src/SharpTS.LanguageServer/Services/NavigationModelBuilder.cs b/src/SharpTS.LanguageServer/Services/NavigationModelBuilder.cs
index aa0d5fa8e..49191d736 100644
--- a/src/SharpTS.LanguageServer/Services/NavigationModelBuilder.cs
+++ b/src/SharpTS.LanguageServer/Services/NavigationModelBuilder.cs
@@ -5,14 +5,20 @@
using SharpTS.Parsing;
using SharpTS.TypeSystem;
using SharpTS.TypeSystem.Exceptions;
+using System.Collections.Immutable;
using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range;
namespace SharpTS.LanguageServer.Services;
internal sealed record CheckedNavigationModel(
- TypeChecker Checker,
- SourceDocument Document,
- NavigationGraphScope Scope);
+ AnalysisSnapshot Snapshot,
+ SourceDocument Document)
+{
+ public NavigationGraphScope Scope => Snapshot.Scope;
+ public FrozenBindingIndex Bindings => Snapshot.Bindings;
+ public FrozenMemberIndex Members => Snapshot.Members;
+ public FrozenEditorSemanticIndex EditorFacts => Snapshot.EditorFacts;
+}
///
/// Describes the bounded root set used to discover reverse navigation edges.
@@ -36,7 +42,10 @@ internal static class NavigationModelBuilder
string path,
string text,
IReadOnlyDictionary? openDocuments,
- CancellationToken cancellationToken = default)
+ CancellationToken cancellationToken = default,
+ Action? beforeCheck = null,
+ bool hasPartialMetadata = false,
+ EditorParseTarget? editorParseTarget = null)
{
cancellationToken.ThrowIfCancellationRequested();
string absolutePath = Path.GetFullPath(path);
@@ -69,10 +78,13 @@ internal static class NavigationModelBuilder
overlay,
configuredWorkspace,
requireMembership: true,
- cancellationToken);
+ cancellationToken, beforeCheck, hasPartialMetadata, editorParseTarget);
if (configured.Model is not null)
return configured.Model;
}
+ // Cursor facts must use the captured configured program. Falling back to default
+ // options after a load/check/membership failure could invent usable candidates.
+ if (editorParseTarget is not null) return null;
}
return TryBuildProject(
@@ -80,7 +92,7 @@ internal static class NavigationModelBuilder
overlay,
NavigationWorkspace.Unconfigured(absolutePath, configPath),
requireMembership: false,
- cancellationToken).Model;
+ cancellationToken, beforeCheck, hasPartialMetadata, editorParseTarget).Model;
}
public static CheckedNavigationWorkspace BuildWorkspace(
@@ -88,7 +100,11 @@ public static CheckedNavigationWorkspace BuildWorkspace(
string text,
IReadOnlyDictionary? openDocuments,
IReadOnlyList workspaceRoots,
- CancellationToken cancellationToken = default)
+ CancellationToken cancellationToken = default,
+ Action? beforeCheck = null,
+ IReadOnlyList? reuseModels = null,
+ bool preserveCapturedOverlay = false,
+ bool hasPartialMetadata = false)
{
cancellationToken.ThrowIfCancellationRequested();
string absolutePath = Path.GetFullPath(path);
@@ -96,6 +112,8 @@ public static CheckedNavigationWorkspace BuildWorkspace(
absolutePath,
text,
openDocuments);
+ if (preserveCapturedOverlay && openDocuments?.ContainsKey(absolutePath) != true)
+ overlay.Remove(absolutePath);
NavigationProjectCatalog catalog =
NavigationProjectCatalog.Discover(workspaceRoots);
var models = new List();
@@ -104,20 +122,29 @@ public static CheckedNavigationWorkspace BuildWorkspace(
foreach (TsConfigResult project in catalog.Projects)
{
cancellationToken.ThrowIfCancellationRequested();
+ CheckedNavigationModel? reused = reuseModels?.FirstOrDefault(model =>
+ string.Equals(model.Scope.ConfigPath, project.ConfigPath, StringComparison.OrdinalIgnoreCase) &&
+ model.Snapshot.TryGetDocument(absolutePath, out _));
+ if (reused is not null && reused.Snapshot.TryGetDocument(absolutePath, out var reusedDocument))
+ {
+ models.Add(new CheckedNavigationModel(reused.Snapshot, reusedDocument!.Document));
+ isComplete &= reused.Scope.IsComplete;
+ continue;
+ }
ProjectBuildResult result = TryBuildProject(
absolutePath,
overlay,
NavigationWorkspace.FromProject(project),
requireMembership: true,
- cancellationToken);
+ cancellationToken, beforeCheck, hasPartialMetadata);
isComplete &= result.IsComplete;
if (result.Model is not null)
models.Add(result.Model);
}
return new CheckedNavigationWorkspace(
- models,
- catalog.ConfigPaths,
+ models.ToImmutableArray(),
+ catalog.ConfigPaths.ToImmutableArray(),
isComplete && models.Count > 0);
}
@@ -126,11 +153,16 @@ private static ProjectBuildResult TryBuildProject(
IReadOnlyDictionary overlay,
NavigationWorkspace workspace,
bool requireMembership,
- CancellationToken cancellationToken)
+ CancellationToken cancellationToken,
+ Action? beforeCheck,
+ bool hasPartialMetadata,
+ EditorParseTarget? editorParseTarget = null)
{
ModuleResolver resolver;
List modulesToCheck;
- CheckedNavigationModel model;
+ TypeChecker checker;
+ SourceDocument document;
+ NavigationGraphScope scope;
bool isComplete;
try
{
@@ -142,7 +174,10 @@ private static ProjectBuildResult TryBuildProject(
virtualFilesFallBackToDisk: true)
{
JsxOptions = workspace.JsxOptions,
- };
+ RecoverParseErrors = true,
+ CaptureEditorSyntax = true,
+ EditorParseTarget = editorParseTarget,
+ }.WithCancellation(cancellationToken);
bool allRootsLoaded = true;
List declarationRoots = [];
@@ -151,9 +186,11 @@ private static ProjectBuildResult TryBuildProject(
cancellationToken.ThrowIfCancellationRequested();
try
{
- declarationRoots.Add(
- resolver.LoadModule(declarationPath, workspace.DecoratorMode));
+ ParsedModule declaration = resolver.LoadModule(declarationPath, workspace.DecoratorMode);
+ declarationRoots.Add(declaration);
+ allRootsLoaded &= declaration.ParseDiagnostics.Count == 0;
}
+ catch (OperationCanceledException) { throw; }
catch
{
allRootsLoaded = false;
@@ -172,9 +209,11 @@ private static ProjectBuildResult TryBuildProject(
ParsedModule root = resolver.LoadProgram(
rootPath,
workspace.DecoratorMode);
+ allRootsLoaded &= root.ParseDiagnostics.Count == 0;
if (!loadedRoots.Contains(root))
loadedRoots.Add(root);
}
+ catch (OperationCanceledException) { throw; }
catch
{
allRootsLoaded = false;
@@ -182,13 +221,38 @@ private static ProjectBuildResult TryBuildProject(
}
ParsedModule? entry = resolver.GetCachedModule(absolutePath);
+ if (entry is null)
+ {
+ // URI captures can spell a Windows drive differently from discovered roots.
+ // Select only an already-loaded, unambiguous physical source document; resolver
+ // virtual identities and nominal module objects must remain distinct.
+ StringComparer pathComparer = OperatingSystem.IsWindows()
+ ? StringComparer.OrdinalIgnoreCase
+ : StringComparer.Ordinal;
+ foreach (ParsedModule candidate in resolver.GetModulesInOrder(
+ declarationRoots.Concat(loadedRoots)))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (candidate.Document is not { IsVirtual: false } candidateDocument ||
+ !Path.IsPathFullyQualified(candidateDocument.Path) ||
+ !pathComparer.Equals(Path.GetFullPath(candidateDocument.Path), absolutePath))
+ {
+ continue;
+ }
+
+ if (entry is not null && !ReferenceEquals(entry, candidate))
+ return new ProjectBuildResult(null, IsComplete: false);
+ entry = candidate;
+ }
+ }
bool isMember = entry is not null;
if (entry is null && requireMembership)
return new ProjectBuildResult(null, allRootsLoaded);
entry ??= resolver.LoadProgram(absolutePath, workspace.DecoratorMode);
- if (entry.Document is not { } document)
+ if (entry.Document is not { } entryDocument)
return new ProjectBuildResult(null, IsComplete: false);
+ document = entryDocument;
if (!loadedRoots.Contains(entry))
loadedRoots.Add(entry);
@@ -217,6 +281,7 @@ private static ProjectBuildResult TryBuildProject(
if (!loadedRoots.Contains(root))
loadedRoots.Add(root);
}
+ catch (OperationCanceledException) { throw; }
catch
{
// Configured roots, not unrelated dirty buffers, define completeness.
@@ -227,23 +292,22 @@ private static ProjectBuildResult TryBuildProject(
declarationRoots.Concat(loadedRoots));
HashSet