From 25a10a22a52474d0bab9810b0cff2c253cd9a932 Mon Sep 17 00:00:00 2001 From: ancplua Date: Thu, 25 Jun 2026 12:27:05 +0200 Subject: [PATCH 01/10] experiment(gate0): prove RegisterPreCompilationSourceOutput callable end-to-end Roslyn #83088 (merged 2026-05-20, main=5.9.0) ships the experimental two-phase pre-compilation API. SDK 10.0.301 and all stable nuget toolsets lack it; the dnceng nightly Microsoft.Net.Compilers.Toolset 5.9.0-1.26324.7 carries it (grep-verified). Isolated spike (spike/): pre-compilation phase emits a marker into the initial compilation from AdditionalTextsProvider only; standard phase binds it via GetTypeByMetadataName. Consumer app references both generated types -> green build is end-to-end proof. QYLSPIKE002 confirms cross-phase visibility; runtime prints count=1. GATE 0 PASSED. Confound noted for Phase 8 (nightly compiler vs SDK control). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/experiments/precompilation-verdict.md | 67 +++++++++++++++ spike/Directory.Build.props | 4 + spike/Directory.Packages.props | 6 ++ spike/Qyl.Spike.Consumer/Program.cs | 10 +++ .../Qyl.Spike.Consumer.csproj | 23 +++++ spike/Qyl.Spike.Consumer/spike.contract.txt | 1 + .../PreCompilationSpikeGenerator.cs | 86 +++++++++++++++++++ .../Qyl.Spike.Generator.csproj | 19 ++++ spike/nuget.config | 9 ++ 9 files changed, 225 insertions(+) create mode 100644 docs/experiments/precompilation-verdict.md create mode 100644 spike/Directory.Build.props create mode 100644 spike/Directory.Packages.props create mode 100644 spike/Qyl.Spike.Consumer/Program.cs create mode 100644 spike/Qyl.Spike.Consumer/Qyl.Spike.Consumer.csproj create mode 100644 spike/Qyl.Spike.Consumer/spike.contract.txt create mode 100644 spike/Qyl.Spike.Generator/PreCompilationSpikeGenerator.cs create mode 100644 spike/Qyl.Spike.Generator/Qyl.Spike.Generator.csproj create mode 100644 spike/nuget.config diff --git a/docs/experiments/precompilation-verdict.md b/docs/experiments/precompilation-verdict.md new file mode 100644 index 00000000..13cf66dc --- /dev/null +++ b/docs/experiments/precompilation-verdict.md @@ -0,0 +1,67 @@ +# Experiment: contract-as-Roslyn-symbols via `RegisterPreCompilationSourceOutput` + +**Branch:** `experiment/precompilation-contract` (control = pristine `main`). +**Hypothesis:** moving qyl's contract from generator-baked data (author-time Python → checked-in +`InstrumentationContract.cs`) into compilation-native symbols emitted by Roslyn's experimental +`RegisterPreCompilationSourceOutput` simplifies the generator and unlocks compile-time-only +semantic coverage. AOT is **not** the hypothesis (qyl already solved AOT). + +Status: **GATE 0 PASSED.** Phases 1–8 in progress. Verdict at bottom. + +--- + +## GATE 0 — API callability (PASSED) + +The only permitted abort was GATE 0 failing. It did not fail. + +### API exists (primary-source verified) +- **dotnet/roslyn PR #83088** — "Add RegisterPreCompilationSourceOutput API for incremental + generators", author `chsienki`, **merged 2026-05-20** into `main` (merge SHA + `227a45faebc6441d8350ca930cbb312d7a6e6c92`). +- Public API surface (`src/Compilers/Core/Portable/PublicAPI.Unshipped.txt`, all gated by the + `[RSEXPERIMENTAL007]` experimental attribute): + - `IncrementalGeneratorInitializationContext.RegisterPreCompilationSourceOutput(IncrementalValueProvider, Action)` + - same for `IncrementalValuesProvider` + - `PreCompilationSourceProductionContext.AddSource(string hintName, string source)` / `(hintName, SourceText)` + - `IncrementalGeneratorOutputKind.PreCompilation = 16` +- Experiment id (`RoslynExperiments.cs`): `RSEXPERIMENTAL007`, url `dotnet/roslyn#83089`. + +### Toolchain resolution +- The SDK pinned by `global.json` (10.0.300 → 10.0.301 installed, compiler `5.x` pre-merge) does + **not** carry the API. +- No **stable** nuget.org `Microsoft.Net.Compilers.Toolset` release carries it either — verified by + grepping the bundled `Microsoft.CodeAnalysis.dll` in `4.14.0`, `5.0.0`, `5.3.0`: all **absent**. + The API is `main`-only (stable packages branch from release branches, not `main`). +- Roslyn `main` is version `5.9.0`. The dnceng **`dotnet-tools`** nightly feed + (`https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json`) + carries `Microsoft.Net.Compilers.Toolset 5.9.0-1.26324.7`, whose bundled + `Microsoft.CodeAnalysis.dll` **contains** `RegisterPreCompilationSourceOutput` (grep-confirmed), + and matching `Microsoft.CodeAnalysis.CSharp` / `.Common` at the same version exist on the feed. + +### Empirical spike (under `spike/`, isolated from the repo build graph) +- One `IIncrementalGenerator`: + - **pre-compilation phase** emits `Qyl.Spike.Generated.PreCompilationProbe` into the *initial* + compilation, driven only by `AdditionalTextsProvider.Collect()` (a permitted non-compilation + input); + - **standard phase** resolves it with `compilation.GetTypeByMetadataName(...)` and would raise a + build-breaking `QYLSPIKE001` error if null. +- Consumer app references **both** the pre-comp marker and the standard-phase confirmation type, so + a green build is unfalsifiable end-to-end proof (the SDK's own compiler cannot produce those types). + +Result — compiler override `Microsoft.Net.Compilers.Toolset 5.9.0-1.26324.7`: +``` +CSC : warning QYLSPIKE002: GetTypeByMetadataName('Qyl.Spike.Generated.PreCompilationProbe') + resolved — pre-compilation source IS visible to the standard phase +Build succeeded. 0 Error(s) + +$ dotnet run +PreCompilationProbe.AdditionalFileCount = 1 +StandardPhaseConfirmation.ObservedAdditionalFileCount = 1 +GATE-0 SPIKE: pre-compilation contract proven end to end. +``` + +**Confound flagged for Phase 8:** the experiment branch builds with the `5.9.0` nightly compiler; +the control (`main`) builds with the SDK 10.0.301 compiler. Any generator-time / allocation / +output-size delta therefore conflates *compiler version* with *the refactor*. Phase 8 must pin the +same toolset on the control measurement (or isolate the confound explicitly) or the numbers are not +science. diff --git a/spike/Directory.Build.props b/spike/Directory.Build.props new file mode 100644 index 00000000..87b2b73d --- /dev/null +++ b/spike/Directory.Build.props @@ -0,0 +1,4 @@ + + + diff --git a/spike/Directory.Packages.props b/spike/Directory.Packages.props new file mode 100644 index 00000000..682c815d --- /dev/null +++ b/spike/Directory.Packages.props @@ -0,0 +1,6 @@ + + + + false + + diff --git a/spike/Qyl.Spike.Consumer/Program.cs b/spike/Qyl.Spike.Consumer/Program.cs new file mode 100644 index 00000000..3ec918ab --- /dev/null +++ b/spike/Qyl.Spike.Consumer/Program.cs @@ -0,0 +1,10 @@ +using Qyl.Spike.Generated; + +// This file compiles ONLY if both generated types exist: +// PreCompilationProbe -> emitted by RegisterPreCompilationSourceOutput (pre-compilation phase) +// StandardPhaseConfirmation -> emitted by RegisterSourceOutput AFTER binding the marker (standard phase) +// A green build is therefore unfalsifiable proof the two-phase contract works end to end. + +Console.WriteLine($"PreCompilationProbe.AdditionalFileCount = {PreCompilationProbe.AdditionalFileCount}"); +Console.WriteLine($"StandardPhaseConfirmation.ObservedAdditionalFileCount = {StandardPhaseConfirmation.ObservedAdditionalFileCount}"); +Console.WriteLine("GATE-0 SPIKE: pre-compilation contract proven end to end."); diff --git a/spike/Qyl.Spike.Consumer/Qyl.Spike.Consumer.csproj b/spike/Qyl.Spike.Consumer/Qyl.Spike.Consumer.csproj new file mode 100644 index 00000000..f6360dc1 --- /dev/null +++ b/spike/Qyl.Spike.Consumer/Qyl.Spike.Consumer.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + latest + enable + enable + + + + + + + + + + + + + diff --git a/spike/Qyl.Spike.Consumer/spike.contract.txt b/spike/Qyl.Spike.Consumer/spike.contract.txt new file mode 100644 index 00000000..878d7427 --- /dev/null +++ b/spike/Qyl.Spike.Consumer/spike.contract.txt @@ -0,0 +1 @@ +dummy additional file — its only job is to prove the pre-compilation phase reads AdditionalTextsProvider. diff --git a/spike/Qyl.Spike.Generator/PreCompilationSpikeGenerator.cs b/spike/Qyl.Spike.Generator/PreCompilationSpikeGenerator.cs new file mode 100644 index 00000000..3fdd2dd6 --- /dev/null +++ b/spike/Qyl.Spike.Generator/PreCompilationSpikeGenerator.cs @@ -0,0 +1,86 @@ +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Qyl.Spike.Generator; + +/// +/// GATE-0 probe. Proves the two-phase pre-compilation contract works end to end: +/// PHASE A (pre-compilation): emit a marker type into the INITIAL compilation, reading only +/// AdditionalTextsProvider (a permitted non-compilation input). +/// PHASE B (standard): resolve that marker via GetTypeByMetadataName on the augmented +/// compilation; build-breaking error if it is null. +/// +[Generator(LanguageNames.CSharp)] +public sealed class PreCompilationSpikeGenerator : IIncrementalGenerator +{ + private const string ProbeMetadataName = "Qyl.Spike.Generated.PreCompilationProbe"; + + private static readonly DiagnosticDescriptor CrossPhaseMissing = new( + id: "QYLSPIKE001", + title: "Pre-compilation marker not visible in standard phase", + messageFormat: "GetTypeByMetadataName('{0}') returned null — pre-compilation source did NOT land in the initial compilation", + category: "QylSpike", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor CrossPhaseOk = new( + id: "QYLSPIKE002", + title: "Pre-compilation marker resolved in standard phase", + messageFormat: "GetTypeByMetadataName('{0}') resolved — pre-compilation source IS visible to the standard phase", + category: "QylSpike", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // ---- PHASE A: pre-compilation. ONLY non-compilation inputs allowed here. ---- + // Drive off AdditionalTextsProvider.Collect() so the type is emitted UNCONDITIONALLY + // (advisor failure-variable #2): missing/empty additional files must not gate the type. + var additionalCount = context.AdditionalTextsProvider + .Collect() + .Select(static (texts, _) => texts.Length); + + context.RegisterPreCompilationSourceOutput(additionalCount, static (spc, count) => + { + var src = +$$""" +// emitted in the PRE-COMPILATION phase. +namespace Qyl.Spike.Generated; + +/// Marker proving RegisterPreCompilationSourceOutput emitted into the initial compilation. +public static class PreCompilationProbe +{ + /// Count of AdditionalFiles the pre-compilation phase observed. + public const int AdditionalFileCount = {{count}}; +} +"""; + spc.AddSource("PreCompilationProbe.g.cs", SourceText.From(src, System.Text.Encoding.UTF8)); + }); + + // ---- PHASE B: standard. Reads the augmented compilation (allowed here only). ---- + context.RegisterSourceOutput(context.CompilationProvider, static (spc, compilation) => + { + var probe = compilation.GetTypeByMetadataName(ProbeMetadataName); + if (probe is null) + { + spc.ReportDiagnostic(Diagnostic.Create(CrossPhaseMissing, Location.None, ProbeMetadataName)); + return; + } + + spc.ReportDiagnostic(Diagnostic.Create(CrossPhaseOk, Location.None, ProbeMetadataName)); + spc.AddSource("StandardPhaseConfirmation.g.cs", SourceText.From( +$$""" +// emitted in the STANDARD phase AFTER binding the pre-compilation marker. +namespace Qyl.Spike.Generated; + +/// Marker proving the standard phase bound a symbol declared by the pre-compilation phase. +public static class StandardPhaseConfirmation +{ + /// Echo of the value the pre-compilation phase baked into the marker. + public const int ObservedAdditionalFileCount = PreCompilationProbe.AdditionalFileCount; +} +""", System.Text.Encoding.UTF8)); + }); + } +} diff --git a/spike/Qyl.Spike.Generator/Qyl.Spike.Generator.csproj b/spike/Qyl.Spike.Generator/Qyl.Spike.Generator.csproj new file mode 100644 index 00000000..78d5ce45 --- /dev/null +++ b/spike/Qyl.Spike.Generator/Qyl.Spike.Generator.csproj @@ -0,0 +1,19 @@ + + + + netstandard2.0 + latest + enable + true + true + + $(NoWarn);RSEXPERIMENTAL007 + + + + + + + + diff --git a/spike/nuget.config b/spike/nuget.config new file mode 100644 index 00000000..c1ef01e7 --- /dev/null +++ b/spike/nuget.config @@ -0,0 +1,9 @@ + + + + + + + + + From a72c420c4147c29fcb43ad67ec7d010f047b6faa Mon Sep 17 00:00:00 2001 From: ancplua Date: Thu, 25 Jun 2026 12:56:10 +0200 Subject: [PATCH 02/10] experiment(verdict): contract-as-precompilation-symbols = DREAMING (separable H2 merit) Isolated real-data ablation (experiment/contract-precompilation/, outside the slnx) of the two-phase RegisterPreCompilationSourceOutput contract. Pre-compilation phase emits qyl's 37 contract capabilities as Qyl.Generated.Contract.* symbols from the resolved-YAML projection; standard phase binds them via GetTypeByMetadataName and runs deterministic compile-time semconv inference over user DTOs (CustomerId->customer.id, +4). Reproducer asserts the result. Verdict DREAMING by the mission's own 5-way-AND GENIUS bar (only 'compile-time-only coverage' holds): generator LOGIC roughly doubles (parser + two-phase + emit->compile->bind-back + polyfill) and gains a hard dependency on the unshipped main-only RSEXPERIMENTAL007 compiler; listeners do not shrink (~95% runtime value extraction, only 3 static-semantic attrs); the contract has a single internal consumer so the API's unique power (symbols into the initial compilation / cross-generator visibility) buys qyl nothing. Decisive: every real win is SEPARABLE from the API -- H2 needs only standard-phase analysis; the maintenance win is a ~20-line Python change. The phase boundary pushed every win into the standard phase => 'pre-compilation bought nothing' trigger fired. Production code untouched; package-layout + contract-invariants verifiers re-run green. Bare nightly compiler builds core runtime 0/0 (floor not broken by the compiler itself). Full verifier suite not re-run; production generator not refactored in place (H1 is an argued projection, stated as such). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/experiments/precompilation-verdict.md | 120 +++++++++ .../Directory.Build.props | 5 + .../Directory.Packages.props | 5 + .../Qyl.Contract.Consumer/Dtos.cs | 7 + .../Qyl.Contract.Consumer/Program.cs | 24 ++ .../Qyl.Contract.Consumer.csproj | 24 ++ .../Qyl.Contract.Consumer/qyl-contract.tsv | 38 +++ .../Qyl.Contract.Consumer/semantic-seeds.tsv | 5 + .../ContractPreCompilationGenerator.cs | 235 ++++++++++++++++++ .../Qyl.Contract.Generator/Polyfills.cs | 6 + .../Qyl.Contract.Generator.csproj | 17 ++ .../contract-precompilation/nuget.config | 8 + tools/verify-precompilation-experiment.py | 84 +++++++ 13 files changed, 578 insertions(+) create mode 100644 experiment/contract-precompilation/Directory.Build.props create mode 100644 experiment/contract-precompilation/Directory.Packages.props create mode 100644 experiment/contract-precompilation/Qyl.Contract.Consumer/Dtos.cs create mode 100644 experiment/contract-precompilation/Qyl.Contract.Consumer/Program.cs create mode 100644 experiment/contract-precompilation/Qyl.Contract.Consumer/Qyl.Contract.Consumer.csproj create mode 100644 experiment/contract-precompilation/Qyl.Contract.Consumer/qyl-contract.tsv create mode 100644 experiment/contract-precompilation/Qyl.Contract.Consumer/semantic-seeds.tsv create mode 100644 experiment/contract-precompilation/Qyl.Contract.Generator/ContractPreCompilationGenerator.cs create mode 100644 experiment/contract-precompilation/Qyl.Contract.Generator/Polyfills.cs create mode 100644 experiment/contract-precompilation/Qyl.Contract.Generator/Qyl.Contract.Generator.csproj create mode 100644 experiment/contract-precompilation/nuget.config create mode 100644 tools/verify-precompilation-experiment.py diff --git a/docs/experiments/precompilation-verdict.md b/docs/experiments/precompilation-verdict.md index 13cf66dc..b1840e51 100644 --- a/docs/experiments/precompilation-verdict.md +++ b/docs/experiments/precompilation-verdict.md @@ -65,3 +65,123 @@ the control (`main`) builds with the SDK 10.0.301 compiler. Any generator-time / output-size delta therefore conflates *compiler version* with *the refactor*. Phase 8 must pin the same toolset on the control measurement (or isolate the confound explicitly) or the numbers are not science. + +--- + +## What was built (hard scope) + +The mission's 8 phases were scoped to the **decisive minimum** that answers the hypothesis with +real artifacts rather than argument: a full contract-as-symbols ablation on one isolated consumer +surface, plus one genuine compile-time-only coverage slice. Everything else is documented as +not-run (below), not silently implied. + +- **Built** (`experiment/contract-precompilation/`, isolated like `spike/`, outside the slnx): + - Python projects the real resolved contract (`docs/generated/qyl-aot-contract.resolved.yaml`) + → a 37-row `qyl-contract.tsv` (this is Phase 1's "Python shrinks to producing resolved data"). + - `ContractPreCompilationGenerator` (235 lines): **pre-compilation phase** parses the TSV + + seed file and emits `Qyl.Generated.Contract.*` symbols (per-item marker types, + `InstrumentationCapability` records, `SemanticAttributeDescriptor` records, `ContractRegistry`, + `SemanticSeeds`) into the initial compilation; **standard phase** binds them via + `GetTypeByMetadataName` and runs Phase-5 inference over user DTOs. + - Consumer fixture references both phases' symbols → green build + correct runtime output is + end-to-end proof (`tools/verify-precompilation-experiment.py` re-asserts it). +- **Measured result** (real data): 37 contract capabilities emitted as symbols and bound in the + standard phase (`BoundCapabilityCount = 37`); `signals.traces.ASPNET` stays + `UnsupportedNativeAot` (the 4 unsupported items stay unsupported); 5 compile-time-only semconv + attributes inferred from user DTO properties (`CustomerId→customer.id`, `OrderId→order.id`, + `TenantId→tenant.id`, `CorrelationId→correlation.id`). +- **Not run / scoped out** (honest): + - The production `QylAutoInstrumentationGenerator` was **not** refactored in place. H1 is measured + against an isolated parallel equivalent, so "generator complexity rises" is an **argued + projection** from (the isolated build's added machinery) + (the untouched 4945-line detection + core), not an in-place before/after diff. + - YAML was **not** wired as AdditionalFiles into the ~40 demos; the ~30-verifier suite, + smoketest, and AOT-demo workflows were **not** re-run under the nightly compiler. + - Phases 4/6/7 (per-integration `[TelemetryOperation]` types, runtime-ownership trim, AOT roots) + not implemented — the listener map already shows their surface is tiny (below). + +## Floor status (scoped, not overclaimed) + +Production code is unchanged, so the production floor is green **by construction**; +`verify-package-layout.py` and `verify-contract-invariants.py` were re-run and pass +(`package-layout-ok`, `contract-invariants-ok`). The `spike/` and `experiment/` dirs sit **outside +the slnx / production build graph**, so the whole-repo build does not touch them. The full verifier +suite was **not** re-executed end-to-end. A bounded probe (below) confirms the bare nightly compiler +does not by itself break the floor. + +## Measurements mapped onto the mission's own criteria + +| Mission GENIUS conjunct (all required) | Result | Evidence | +|---|---|---| +| more semconv from semantic analysis than runtime extraction | **FALSE** | H2 yields ~5 (scales w/ DTO count); runtime listeners extract dozens of *values* (method, route, status, db.namespace, query.text, …) that pre-compilation cannot read. Aggregate: runtime ≫ semantic. | +| listeners shrink | **FALSE** | Only 3 attributes are static-semantic (`qyl.instrumentation.domain`, `rpc.system=grpc`, `db.system.name=mssql`); the other ~95% are genuine runtime value reads. Listeners essentially unchanged. | +| interceptor generation simpler | **FALSE** | The 4945-line call-site detection/emission core is untouched; the experiment *adds* a two-phase pipeline + parser + emit→compile→bind round-trip. | +| coverage matrix gains compile-time-only attributes | **TRUE** | H2 demonstrably adds `customer.id`/`order.id`/`tenant.id`/`correlation.id` — impossible for runtime listeners without (forbidden) reflection. | +| generator complexity **and** contract maintenance measurably drop | **NO** | Contract *data* maintenance drops (360 baked C# lines → 37 TSV rows). But generator *logic* roughly doubles and gains a hard dependency on an unshipped compiler. Net complexity rises. | + +GENIUS is an explicit 5-way AND; only one conjunct holds. Multiple DREAMING triggers fire (below). + +### The raw-LOC trap, defused +Naively: experiment generator 235 + polyfill 6 + TSV 37 = **278 < 360** baked lines — looks +*simpler*. That reading is wrong. The baseline's 360 lines are mostly **data** (60 item rows + +key-set arrays + counts + meter consts); its actual **logic** is ~120 lines (record def, `TryGet*`, +`EmitStringArray`, manifest emitter). The experiment's 235 lines are almost entirely **logic** +(TSV parser + two emit methods + symbol binder + DTO inference + namespace walk). So **logic roughly +doubles** while the data compresses into a TSV. The verdict rests on *mechanism*, not line count: +the experiment trades `static array + dictionary lookup` for `TSV parser + two-phase pipeline + +emit→compile→bind-back + IsExternalInit polyfill + dependency on a main-only unshipped compiler`. + +### The spine: every win is separable from the experimental API +This is the decisive finding. Neither benefit needs `RegisterPreCompilationSourceOutput`: +- **H2 coverage** runs entirely in the **standard phase** over `CompilationProvider` + property + names; the seed list is an `AdditionalText`, and `AdditionalTextsProvider` is available in the + standard phase too. The pre-compilation round-trip for the seeds in this experiment is + **demonstrative, not necessary**. +- **The maintenance win** (deleting the hand-maintained `InstrumentationContract.cs`) is achievable + by having `generate-contract-artifacts.py` *emit* that `.cs` at author time — a ~20-line change, + no experimental API. (The mission wrongly assumed Python already does this; it does not — the + `.cs` is hand-authored and kept in sync by `verify-contract-invariants.py`.) + +The API's unique power — emitting symbols into the **initial** compilation, visible to *other* +generators — solves a problem qyl does not have. qyl's contract has a **single internal consumer** +(its own generator), for which baked C# is strictly less work than emit→compile→bind-back. This is +the verbatim mission DREAMING trigger: *"the phase boundary forced so much into the standard phase +that pre-compilation bought nothing."* It fired. + +### Secondary signal: bare-compiler floor probe (confound-aware) +Pinning the nightly toolset on the core runtime project (full AOT/trim/analyzer stack + +`TreatWarningsAsErrors`) built **green (0 warnings, 0 errors)** — so Roslyn 5.9.0 nightly does not +surface new analyzer diagnostics. The real costs are structural, not analyzer breakage: +`NU1507` forces package-source-mapping config for the extra feed, and — decisively — the API is +gated by `[Experimental(RSEXPERIMENTAL007)]`, **unshipped and main-only**. Forcing that onto every +consumer compilation is not viable for a shippable product, independent of any LOC count. Build-time +delta is left as a soft signal only (the nightly-vs-SDK compiler swap is a confound for it; the +structural findings above are confound-immune). + +--- + +## VERDICT: **DREAMING** — with a genuine, separable H2 merit + +Moving qyl's contract from generator-baked data into compilation-native symbols via +`RegisterPreCompilationSourceOutput` does **not** simplify the generator and is **not** how +compile-time-only coverage is unlocked: + +- Generator **logic roughly doubles** (parser + two-phase pipeline + emit→compile→bind-back + + polyfill) and gains a hard dependency on an **unshipped, main-only compiler** — non-shippable. +- **Listeners do not shrink**: ~95% of their work is runtime value extraction the API cannot touch; + only 3 attributes are static-semantic. +- The contract has a **single internal consumer**, so the API's defining capability (symbols into + the initial compilation / cross-generator visibility) buys qyl nothing. The phase boundary pushed + every real win into the standard phase. +- The contract-maintenance improvement is **real but small** (one auto-synced `.cs` removed) and is + obtainable with a 20-line Python change — no experimental API. + +**The one real win is separable and worth keeping:** deterministic, reproducible, standard-phase +**compile-time semantic-convention inference** over user DTOs/routes (`CustomerId → customer.id`) +produces attributes the runtime path genuinely cannot, and it satisfies the Phase-5 "no guessing, +no non-reproducible heuristics" bar. Pursue it as an ordinary `IIncrementalGenerator` standard-phase +feature — **not** behind the experimental pre-compilation API. + +"AOT improved" was explicitly not pursued and is not claimed. By the mission's own definition, with +generator complexity rising and the API buying nothing for qyl's single-consumer contract, this is +DREAMING with a documented, independently-shippable coverage idea — not GENIUS. diff --git a/experiment/contract-precompilation/Directory.Build.props b/experiment/contract-precompilation/Directory.Build.props new file mode 100644 index 00000000..be9f949e --- /dev/null +++ b/experiment/contract-precompilation/Directory.Build.props @@ -0,0 +1,5 @@ + + + diff --git a/experiment/contract-precompilation/Directory.Packages.props b/experiment/contract-precompilation/Directory.Packages.props new file mode 100644 index 00000000..5f9708a9 --- /dev/null +++ b/experiment/contract-precompilation/Directory.Packages.props @@ -0,0 +1,5 @@ + + + false + + diff --git a/experiment/contract-precompilation/Qyl.Contract.Consumer/Dtos.cs b/experiment/contract-precompilation/Qyl.Contract.Consumer/Dtos.cs new file mode 100644 index 00000000..a48619c8 --- /dev/null +++ b/experiment/contract-precompilation/Qyl.Contract.Consumer/Dtos.cs @@ -0,0 +1,7 @@ +namespace Qyl.Contract.Consumer.Contracts; + +// A user DTO. Its property shapes are visible to the compiler but NOT to runtime DiagnosticListeners +// (which would need reflection — forbidden). The standard phase infers semconv bindings from these. +public sealed record OrderRequest(string CustomerId, string TenantId, string OrderId, decimal Amount); + +public sealed record ShipmentEvent(string OrderId, string CorrelationId, string Carrier); diff --git a/experiment/contract-precompilation/Qyl.Contract.Consumer/Program.cs b/experiment/contract-precompilation/Qyl.Contract.Consumer/Program.cs new file mode 100644 index 00000000..fec477c9 --- /dev/null +++ b/experiment/contract-precompilation/Qyl.Contract.Consumer/Program.cs @@ -0,0 +1,24 @@ +using Qyl.Generated.Contract; + +// Both namespaces below are produced ONLY by the two-phase generator: +// ContractRegistry -> pre-compilation phase (contract as symbols) +// ContractBinding -> standard phase (bound the pre-comp symbols + inferred user-DTO semconv) +// A green build that references them is proof the whole contract-as-symbols pipeline works. + +Console.WriteLine($"[pre-comp] ContractRegistry.CapabilityCount = {ContractRegistry.CapabilityCount}"); +Console.WriteLine($"[standard] ContractBinding.BoundCapabilityCount = {ContractBinding.BoundCapabilityCount} (bound via GetTypeByMetadataName)"); + +// The 4 unsupported items must remain unsupported — prove the symbol carries that truth. +var aspnet = ContractRegistry.Capabilities.First(c => c.Key == "signals.traces.ASPNET"); +Console.WriteLine($"[truth] signals.traces.ASPNET status = {aspnet.Status} (stays UnsupportedNativeAot)"); +Console.WriteLine($"[truth] ContractRegistry.IsImplemented(\"signals.traces.ASPNET\") = {ContractRegistry.IsImplemented("signals.traces.ASPNET")}"); +Console.WriteLine($"[truth] ContractRegistry.IsImplemented(\"signals.traces.ADONET\") = {ContractRegistry.IsImplemented("signals.traces.ADONET")}"); + +Console.WriteLine(); +Console.WriteLine($"[H2] compile-time-only semconv inference ({ContractBinding.InferredAttributeCount} attributes, impossible at runtime without reflection):"); +foreach (var (type, prop, attr) in ContractBinding.InferredBindings) + Console.WriteLine($" {type}.{prop} -> {attr}"); + +// Touch the DTOs so they are emitted into the compilation the standard phase inspects. +_ = new Qyl.Contract.Consumer.Contracts.OrderRequest("c", "t", "o", 0m); +_ = new Qyl.Contract.Consumer.Contracts.ShipmentEvent("o", "x", "dhl"); diff --git a/experiment/contract-precompilation/Qyl.Contract.Consumer/Qyl.Contract.Consumer.csproj b/experiment/contract-precompilation/Qyl.Contract.Consumer/Qyl.Contract.Consumer.csproj new file mode 100644 index 00000000..f654a217 --- /dev/null +++ b/experiment/contract-precompilation/Qyl.Contract.Consumer/Qyl.Contract.Consumer.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + latest + enable + enable + + + + + + + + + + + + + + diff --git a/experiment/contract-precompilation/Qyl.Contract.Consumer/qyl-contract.tsv b/experiment/contract-precompilation/Qyl.Contract.Consumer/qyl-contract.tsv new file mode 100644 index 00000000..79393812 --- /dev/null +++ b/experiment/contract-precompilation/Qyl.Contract.Consumer/qyl-contract.tsv @@ -0,0 +1,38 @@ +# key signal lane qyl_status evidence_level required recommended opt_in +signals.traces.ADONET traces source_interceptor implemented verified_nativeaot db.operation.name,db.query.summary,db.system.name,qyl.instrumentation.domain error.type db.query.text +signals.traces.ASPNET traces unsupported_nativeaot unsupported_nativeaot none +signals.traces.ASPNETCORE traces runtime_public_telemetry implemented verified_nativeaot http.request.method,http.response.status_code,http.route,qyl.instrumentation.domain +signals.traces.AZURE traces framework_initialization implemented verified_nativeaot qyl.instrumentation.domain error.type +signals.traces.ELASTICSEARCH traces source_interceptor implemented verified_nativeaot db.operation.name,db.query.summary,db.system.name,qyl.instrumentation.domain error.type +signals.traces.ELASTICTRANSPORT traces source_interceptor implemented verified_nativeaot db.operation.name,db.query.summary,db.system.name,qyl.instrumentation.domain error.type +signals.traces.ENTITYFRAMEWORKCORE traces runtime_public_telemetry implemented verified_nativeaot db.operation.name,db.query.summary,db.system.name,qyl.instrumentation.domain db.query.text +signals.traces.GRAPHQL traces source_interceptor implemented verified_nativeaot graphql.operation.name,qyl.instrumentation.domain error.type graphql.document +signals.traces.GRPCNETCLIENT traces runtime_public_telemetry implemented verified_nativeaot qyl.instrumentation.domain,rpc.grpc.status_code,rpc.method,rpc.service,rpc.system error.type +signals.traces.HTTPCLIENT traces runtime_public_telemetry implemented verified_nativeaot http.request.method,http.response.status_code,qyl.instrumentation.domain,server.address,server.port +signals.traces.KAFKA traces source_interceptor implemented verified_nativeaot messaging.operation.name,messaging.operation.type,messaging.system,qyl.instrumentation.domain error.type +signals.traces.MASSTRANSIT traces source_interceptor implemented verified_nativeaot messaging.operation.name,messaging.operation.type,messaging.system,qyl.instrumentation.domain error.type +signals.traces.MONGODB traces source_interceptor implemented verified_nativeaot db.operation.name,db.query.summary,db.system.name,qyl.instrumentation.domain error.type +signals.traces.MYSQLCONNECTOR traces source_interceptor implemented verified_nativeaot db.operation.name,db.query.summary,db.system.name,qyl.instrumentation.domain error.type db.query.text +signals.traces.MYSQLDATA traces source_interceptor implemented verified_nativeaot db.operation.name,db.query.summary,db.system.name,qyl.instrumentation.domain error.type db.query.text +signals.traces.NPGSQL traces source_interceptor implemented verified_nativeaot db.operation.name,db.query.summary,db.system.name,qyl.instrumentation.domain error.type db.query.text +signals.traces.NSERVICEBUS traces source_interceptor implemented verified_managed +signals.traces.ORACLEMDA traces source_interceptor implemented verified_nativeaot db.operation.name,db.query.summary,db.system.name,qyl.instrumentation.domain error.type db.query.text +signals.traces.RABBITMQ traces source_interceptor implemented verified_nativeaot messaging.operation.name,messaging.operation.type,messaging.system,qyl.instrumentation.domain error.type +signals.traces.QUARTZ traces source_interceptor implemented verified_nativeaot qyl.instrumentation.domain error.type +signals.traces.SQLCLIENT traces source_interceptor implemented verified_nativeaot db.operation.name,db.query.summary,db.system.name,qyl.instrumentation.domain error.type db.query.text +signals.traces.SQLITE traces source_interceptor implemented verified_nativeaot db.operation.name,db.query.summary,db.system.name,qyl.instrumentation.domain error.type db.query.text +signals.traces.STACKEXCHANGEREDIS traces source_interceptor implemented verified_nativeaot db.operation.name,db.query.summary,db.system.name,qyl.instrumentation.domain error.type db.query.text +signals.traces.WCFCLIENT traces source_interceptor implemented verified_managed +signals.traces.WCFCORE traces unsupported_nativeaot unsupported_nativeaot none +signals.traces.WCFSERVICE traces unsupported_nativeaot unsupported_nativeaot none +signals.metrics.ASPNET metrics unsupported_nativeaot unsupported_nativeaot none +signals.metrics.ASPNETCORE metrics runtime_public_telemetry implemented verified_nativeaot +signals.metrics.HTTPCLIENT metrics runtime_public_telemetry implemented verified_nativeaot http.request.method error.type,http.response.status_code +signals.metrics.NETRUNTIME metrics runtime_public_telemetry implemented verified_nativeaot +signals.metrics.NPGSQL metrics source_interceptor implemented verified_nativeaot db.system.name +signals.metrics.NSERVICEBUS metrics source_interceptor implemented verified_managed +signals.metrics.PROCESS metrics runtime_public_telemetry implemented verified_nativeaot +signals.metrics.SQLCLIENT metrics source_interceptor implemented verified_nativeaot db.system.name +signals.logs.ILOGGER logs source_interceptor implemented verified_nativeaot log.severity,qyl.instrumentation.domain error.type +signals.logs.LOG4NET logs source_interceptor implemented verified_nativeaot log.severity,qyl.instrumentation.domain +signals.logs.NLOG logs source_interceptor implemented verified_nativeaot log.severity,qyl.instrumentation.domain diff --git a/experiment/contract-precompilation/Qyl.Contract.Consumer/semantic-seeds.tsv b/experiment/contract-precompilation/Qyl.Contract.Consumer/semantic-seeds.tsv new file mode 100644 index 00000000..e6ad3c8c --- /dev/null +++ b/experiment/contract-precompilation/Qyl.Contract.Consumer/semantic-seeds.tsv @@ -0,0 +1,5 @@ +# property-name semantic-convention-attribute (seed index — pre-compilation input) +CustomerId customer.id +TenantId tenant.id +OrderId order.id +CorrelationId correlation.id diff --git a/experiment/contract-precompilation/Qyl.Contract.Generator/ContractPreCompilationGenerator.cs b/experiment/contract-precompilation/Qyl.Contract.Generator/ContractPreCompilationGenerator.cs new file mode 100644 index 00000000..8bf3579a --- /dev/null +++ b/experiment/contract-precompilation/Qyl.Contract.Generator/ContractPreCompilationGenerator.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Qyl.Contract.Generator; + +/// +/// Experiment: qyl's contract as compilation-native Roslyn symbols. +/// +/// PHASE A (pre-compilation, AdditionalTexts ONLY): parse the resolved-contract TSV and the +/// semantic-seed TSV; emit the contract as strongly-typed symbols into the INITIAL compilation +/// (namespace Qyl.Generated.Contract): per-item marker types, InstrumentationCapability records, +/// SemanticAttributeDescriptor records, and a SemanticSeeds constant table. This replaces the +/// hand-authored InstrumentationContract.cs string/dictionary tables. +/// +/// PHASE B (standard, reads the augmented compilation + user C#): BIND the pre-compilation symbols +/// via GetTypeByMetadataName (the compilation is the cache — no YAML re-parse), read the seed map +/// back out of the bound SemanticSeeds symbol's constants, then inspect USER DTO properties and +/// emit deterministic semantic-convention bindings (CustomerId -> customer.id). That inference is +/// compile-time-only: the runtime DiagnosticListeners never see user DTO shapes. +/// +[Generator(LanguageNames.CSharp)] +public sealed class ContractPreCompilationGenerator : IIncrementalGenerator +{ + private const string Ns = "Qyl.Generated.Contract"; + + private static readonly DiagnosticDescriptor PreCompMissing = new( + "QYLC001", "Pre-compilation contract symbol missing", + "GetTypeByMetadataName('{0}') returned null — the pre-compilation contract did NOT land in the compilation", + "QylContract", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // ---------------- PHASE A: PRE-COMPILATION (non-compilation inputs only) ---------------- + var contractText = context.AdditionalTextsProvider + .Where(static t => t.Path.Replace('\\', '/').EndsWith("qyl-contract.tsv", StringComparison.OrdinalIgnoreCase)) + .Select(static (t, ct) => t.GetText(ct)?.ToString() ?? string.Empty) + .Collect(); + + context.RegisterPreCompilationSourceOutput(contractText, static (spc, texts) => + { + var rows = ParseContract(string.Concat(texts)); + spc.AddSource("Contract.Capabilities.g.cs", SourceText.From(BuildContractSource(rows), Encoding.UTF8)); + }); + + var seedText = context.AdditionalTextsProvider + .Where(static t => t.Path.Replace('\\', '/').EndsWith("semantic-seeds.tsv", StringComparison.OrdinalIgnoreCase)) + .Select(static (t, ct) => t.GetText(ct)?.ToString() ?? string.Empty) + .Collect(); + + context.RegisterPreCompilationSourceOutput(seedText, static (spc, texts) => + { + var seeds = ParseSeeds(string.Concat(texts)); + spc.AddSource("Contract.SemanticSeeds.g.cs", SourceText.From(BuildSeedsSource(seeds), Encoding.UTF8)); + }); + + // ---------------- PHASE B: STANDARD (compilation + user code) ---------------- + context.RegisterSourceOutput(context.CompilationProvider, static (spc, compilation) => + { + var registry = compilation.GetTypeByMetadataName($"{Ns}.ContractRegistry"); + var seedsType = compilation.GetTypeByMetadataName($"{Ns}.SemanticSeeds"); + if (registry is null) { spc.ReportDiagnostic(Diagnostic.Create(PreCompMissing, Location.None, $"{Ns}.ContractRegistry")); return; } + if (seedsType is null) { spc.ReportDiagnostic(Diagnostic.Create(PreCompMissing, Location.None, $"{Ns}.SemanticSeeds")); return; } + + // Bound from the pre-compilation symbol — the compilation is the cache, not a re-parsed file. + var boundCount = registry.GetMembers("CapabilityCount").OfType() + .Select(f => f.ConstantValue).OfType().FirstOrDefault(); + + // Seed map read out of the bound SemanticSeeds symbol's const fields. + var seedMap = seedsType.GetMembers().OfType() + .Where(f => f.HasConstantValue && f.ConstantValue is string) + .ToDictionary(f => f.Name, f => (string)f.ConstantValue!, StringComparer.Ordinal); + + // Compile-time-only semantic inference over USER types (deterministic, reproducible). + var bindings = InferBindings(compilation, seedMap); + spc.AddSource("Contract.Binding.g.cs", SourceText.From(BuildBindingSource(boundCount, bindings), Encoding.UTF8)); + }); + } + + // ---- parsing (trivial; the YAML source of truth stays Python-owned) ---- + private readonly record struct ContractRow(string Key, string Signal, string Lane, string Status, string Evidence, + string[] Required, string[] Recommended, string[] OptIn); + + private static List ParseContract(string tsv) + { + var rows = new List(); + foreach (var raw in tsv.Split('\n')) + { + var line = raw.TrimEnd('\r'); + if (line.Length == 0 || line[0] == '#') continue; + var c = line.Split('\t'); + if (c.Length < 8) continue; + rows.Add(new ContractRow(c[0], c[1], c[2], c[3], c[4], Csv(c[5]), Csv(c[6]), Csv(c[7]))); + } + return rows; + } + + private static string[] Csv(string s) => + s.Length == 0 ? Array.Empty() : s.Split(',').Where(x => x.Length > 0).ToArray(); + + private static List> ParseSeeds(string tsv) + { + var seeds = new List>(); + foreach (var raw in tsv.Split('\n')) + { + var line = raw.TrimEnd('\r'); + if (line.Length == 0 || line[0] == '#') continue; + var c = line.Split('\t'); + if (c.Length < 2) continue; + seeds.Add(new KeyValuePair(c[0].Trim(), c[1].Trim())); + } + return seeds; + } + + // ---- emission: the contract as symbols ---- + private static string PascalKey(string key) => + string.Concat(key.Split('.', '_').Select(p => p.Length == 0 ? "" : char.ToUpperInvariant(p[0]) + p.Substring(1))); + + private static string BuildContractSource(List rows) + { + var b = new StringBuilder(); + b.AppendLine("// pre-compilation phase: contract -> compilation-native symbols."); + b.AppendLine("#nullable enable"); + b.AppendLine($"namespace {Ns};"); + b.AppendLine(); + b.AppendLine("public enum CapabilityStatus { Implemented, ControlBound, OptionBound, ResearchRequired, UnsupportedNativeAot }"); + b.AppendLine("public enum AttributeRequirement { Required, Recommended, OptIn }"); + b.AppendLine("public sealed record InstrumentationCapability(string Key, CapabilityStatus Status, string Lane, string Evidence);"); + b.AppendLine("public sealed record SemanticAttributeDescriptor(string OwningKey, string AttributeName, AttributeRequirement Requirement);"); + b.AppendLine(); + + // per-item marker types (one symbol per contract row) + foreach (var r in rows) + b.AppendLine($"/// Marker for contract row {r.Key} ({r.Status}).\npublic sealed partial class {PascalKey(r.Key)} {{ public const string Key = \"{r.Key}\"; }}"); + b.AppendLine(); + + b.AppendLine("public static class ContractRegistry"); + b.AppendLine("{"); + b.AppendLine($" public const int CapabilityCount = {rows.Count};"); + b.AppendLine(" public static readonly InstrumentationCapability[] Capabilities ="); + b.AppendLine(" {"); + foreach (var r in rows) + b.AppendLine($" new(\"{r.Key}\", CapabilityStatus.{Status(r.Status)}, \"{r.Lane}\", \"{r.Evidence}\"),"); + b.AppendLine(" };"); + b.AppendLine(); + b.AppendLine(" public static readonly SemanticAttributeDescriptor[] Attributes ="); + b.AppendLine(" {"); + foreach (var r in rows) + { + foreach (var a in r.Required) b.AppendLine($" new(\"{r.Key}\", \"{a}\", AttributeRequirement.Required),"); + foreach (var a in r.Recommended) b.AppendLine($" new(\"{r.Key}\", \"{a}\", AttributeRequirement.Recommended),"); + foreach (var a in r.OptIn) b.AppendLine($" new(\"{r.Key}\", \"{a}\", AttributeRequirement.OptIn),"); + } + b.AppendLine(" };"); + b.AppendLine(" public static bool IsImplemented(string key)"); + b.AppendLine(" {"); + b.AppendLine(" foreach (var c in Capabilities) if (c.Key == key && c.Status == CapabilityStatus.Implemented) return true;"); + b.AppendLine(" return false;"); + b.AppendLine(" }"); + b.AppendLine("}"); + return b.ToString(); + } + + private static string Status(string s) => s switch + { + "implemented" => "Implemented", + "control_bound" => "ControlBound", + "option_bound" => "OptionBound", + "unsupported_nativeaot" => "UnsupportedNativeAot", + _ => "ResearchRequired", + }; + + private static string BuildSeedsSource(List> seeds) + { + var b = new StringBuilder(); + b.AppendLine("// pre-compilation phase: semantic-convention seed index."); + b.AppendLine($"namespace {Ns};"); + b.AppendLine("/// Seed map (property-name -> semantic-convention attribute). Lives in the compilation."); + b.AppendLine("public static class SemanticSeeds"); + b.AppendLine("{"); + foreach (var s in seeds) + b.AppendLine($" public const string {s.Key} = \"{s.Value}\";"); + b.AppendLine($" public const int SeedCount = {seeds.Count};"); + b.AppendLine("}"); + return b.ToString(); + } + + private readonly record struct Binding(string DeclaringType, string Property, string Attribute); + + private static List InferBindings(Compilation compilation, Dictionary seedMap) + { + var bindings = new List(); + if (seedMap.Count == 0) return bindings; + foreach (var type in EnumerateSourceTypes(compilation.GlobalNamespace)) + { + foreach (var prop in type.GetMembers().OfType()) + { + if (seedMap.TryGetValue(prop.Name, out var attr)) + bindings.Add(new Binding(type.ToDisplayString(), prop.Name, attr)); + } + } + return bindings.OrderBy(x => x.DeclaringType, StringComparer.Ordinal).ThenBy(x => x.Property, StringComparer.Ordinal).ToList(); + } + + private static IEnumerable EnumerateSourceTypes(INamespaceSymbol ns) + { + foreach (var t in ns.GetTypeMembers()) + if (t.Locations.Any(l => l.IsInSource)) + yield return t; + foreach (var child in ns.GetNamespaceMembers()) + foreach (var t in EnumerateSourceTypes(child)) + yield return t; + } + + private static string BuildBindingSource(int boundCount, List bindings) + { + var b = new StringBuilder(); + b.AppendLine("// standard phase: bound pre-compilation symbols + compile-time-only inference."); + b.AppendLine($"namespace {Ns};"); + b.AppendLine("public static class ContractBinding"); + b.AppendLine("{"); + b.AppendLine($" public const int BoundCapabilityCount = {boundCount};"); + b.AppendLine($" public const int InferredAttributeCount = {bindings.Count};"); + b.AppendLine(" public static readonly (string Type, string Property, string Attribute)[] InferredBindings ="); + b.AppendLine(" {"); + foreach (var x in bindings) + b.AppendLine($" (\"{x.DeclaringType}\", \"{x.Property}\", \"{x.Attribute}\"),"); + b.AppendLine(" };"); + b.AppendLine("}"); + return b.ToString(); + } +} diff --git a/experiment/contract-precompilation/Qyl.Contract.Generator/Polyfills.cs b/experiment/contract-precompilation/Qyl.Contract.Generator/Polyfills.cs new file mode 100644 index 00000000..617ad977 --- /dev/null +++ b/experiment/contract-precompilation/Qyl.Contract.Generator/Polyfills.cs @@ -0,0 +1,6 @@ +// netstandard2.0 has no IsExternalInit; records/init require it. The production generator gets this +// from ANcpLua.Roslyn.Utilities.Sources — the isolated experiment supplies it directly. +namespace System.Runtime.CompilerServices +{ + internal static class IsExternalInit { } +} diff --git a/experiment/contract-precompilation/Qyl.Contract.Generator/Qyl.Contract.Generator.csproj b/experiment/contract-precompilation/Qyl.Contract.Generator/Qyl.Contract.Generator.csproj new file mode 100644 index 00000000..9f0e640e --- /dev/null +++ b/experiment/contract-precompilation/Qyl.Contract.Generator/Qyl.Contract.Generator.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0 + latest + enable + true + true + + $(NoWarn);RSEXPERIMENTAL007;RS2008 + + + + + + + diff --git a/experiment/contract-precompilation/nuget.config b/experiment/contract-precompilation/nuget.config new file mode 100644 index 00000000..131303b8 --- /dev/null +++ b/experiment/contract-precompilation/nuget.config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tools/verify-precompilation-experiment.py b/tools/verify-precompilation-experiment.py new file mode 100644 index 00000000..25dc78af --- /dev/null +++ b/tools/verify-precompilation-experiment.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Reproduce the pre-compilation contract experiment and assert its measured result. + +This is the Phase-8 reproducer for docs/experiments/precompilation-verdict.md. It builds the +ISOLATED experiment under experiment/contract-precompilation/ (which pins the Roslyn nightly +toolset 5.9.0-1.26324.7 carrying RegisterPreCompilationSourceOutput, dotnet/roslyn#83088) and +checks the two-phase contract pipeline produced the expected symbols and compile-time-only +inference. Requires network access to the dnceng dotnet-tools nightly feed; skips (exit 0) if the +toolset cannot be restored, so it never breaks an offline floor run. + +NOT wired into verify-aot-autoinstrumentation-goal.py: the experiment lives outside the slnx / +production build graph and depends on an unshipped compiler. +""" +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CONSUMER = ROOT / "experiment" / "contract-precompilation" / "Qyl.Contract.Consumer" + +EXPECTED = { + "pre-comp capability count": (r"ContractRegistry\.CapabilityCount = (\d+)", "37"), + "standard-phase bound count": (r"BoundCapabilityCount = (\d+)", "37"), + "ASPNET stays unsupported": (r"signals\.traces\.ASPNET status = (\w+)", "UnsupportedNativeAot"), + "inferred attribute count": (r"compile-time-only semconv inference \((\d+) attributes", "5"), +} +EXPECTED_BINDINGS = { + "OrderRequest.CustomerId -> customer.id", + "OrderRequest.OrderId -> order.id", + "OrderRequest.TenantId -> tenant.id", + "ShipmentEvent.CorrelationId -> correlation.id", + "ShipmentEvent.OrderId -> order.id", +} + + +def run(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(args, cwd=CONSUMER, capture_output=True, text=True) + + +def main() -> int: + if not CONSUMER.exists(): + print(f"experiment-missing: {CONSUMER}", file=sys.stderr) + return 1 + + build = run(["dotnet", "build", "-c", "Debug"]) + if build.returncode != 0: + tail = "\n".join(build.stdout.splitlines()[-15:]) + if re.search(r"NU1\d{3}|unable to load the service index|Unable to find package", build.stdout): + print("precompilation-experiment-skip: roslyn nightly feed unreachable") + return 0 + print("precompilation-experiment-FAIL: build error\n" + tail, file=sys.stderr) + return 1 + + proc = run(["dotnet", "run", "-c", "Debug", "--no-build"]) + out = proc.stdout + if proc.returncode != 0: + print("precompilation-experiment-FAIL: run error\n" + proc.stderr, file=sys.stderr) + return 1 + + failures: list[str] = [] + for label, (pattern, want) in EXPECTED.items(): + m = re.search(pattern, out) + got = m.group(1) if m else "" + if got != want: + failures.append(f"{label}: expected {want!r}, got {got!r}") + + for b in EXPECTED_BINDINGS: + if b not in out: + failures.append(f"missing compile-time-only binding: {b}") + + if failures: + print("precompilation-experiment-FAIL:\n " + "\n ".join(failures), file=sys.stderr) + print("\n--- actual output ---\n" + out, file=sys.stderr) + return 1 + + print("precompilation-experiment-ok (37 contract symbols bound; 5 compile-time-only attributes inferred)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 7c454d03d553476659152ab90291fadf8fa48dfd Mon Sep 17 00:00:00 2001 From: ancplua Date: Thu, 25 Jun 2026 13:03:48 +0200 Subject: [PATCH 03/10] experiment: give spike + experiment their own isolated .slnx (keep them OUT of prod slnx) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit slnx-sync flagged these 4 csproj as unregistered. They MUST stay out of the production Qyl.OpenTelemetry.AutoInstrumentation.slnx: they reference the dnceng nightly feed + experimental Microsoft.Net.Compilers.Toolset that only their local nuget.config carries, so the whole-repo build/gate (root nuget.config, NU1507 single-feed-as-error) would fail to restore them and break the floor. Register them in dedicated isolated solutions instead — both build green (Spike.slnx, Experiment.slnx); production slnx unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- experiment/contract-precompilation/Experiment.slnx | 8 ++++++++ spike/Spike.slnx | 8 ++++++++ 2 files changed, 16 insertions(+) create mode 100644 experiment/contract-precompilation/Experiment.slnx create mode 100644 spike/Spike.slnx diff --git a/experiment/contract-precompilation/Experiment.slnx b/experiment/contract-precompilation/Experiment.slnx new file mode 100644 index 00000000..7228e98e --- /dev/null +++ b/experiment/contract-precompilation/Experiment.slnx @@ -0,0 +1,8 @@ + + + + + diff --git a/spike/Spike.slnx b/spike/Spike.slnx new file mode 100644 index 00000000..b0a6e756 --- /dev/null +++ b/spike/Spike.slnx @@ -0,0 +1,8 @@ + + + + + From 2693fd732b90b19d3961fd5f5bb444edcb60c70c Mon Sep 17 00:00:00 2001 From: ancplua Date: Thu, 25 Jun 2026 23:37:20 +0200 Subject: [PATCH 04/10] experiment(platform): demonstrate 1-producer -> N-consumer semantic composition (#83088) Reframe from the user: the DREAMING verdict scoped to today's 1->1 Qyl; the real value of RegisterPreCompilationSourceOutput is COMPOSITION (cross-generator visibility) under a future 1->N semantic-platform architecture. Built it to test the claim honestly. experiment/semantic-platform/: one producer (SemanticContractProducer) pre-comp-emits a shared [QylSemanticBinding] contract from an additional file into the initial compilation; two independent consumers (OTel keyed by semconv attribute, Logging keyed by property name) bind it via the shared compilation WITHOUT referencing the producer. Green build + divergent output = real 1->N composition. Adversarially verified (4-lens workflow): composition genuine (dual-gate, no side channels); post-init cannot read the additional file so #83088 is genuinely required; RegisterDeclarationOutput (#81395) absent from the nightly (count 0) so DTO-derived shared contracts (the headline vision) remain blocked; pre-comp may not read syntax/compilation (confirmed). Applied review fixes: top-level type scoping, incrementality + property-access caveats documented. Honest boundary: #83088 publishes ADDITIONAL-FILE-derived contracts cross-generator (built, Pattern B); DTO-inference -> N generators (Pattern C) needs unmerged #81395; user-annotation (Pattern A) needs no experimental API. Composition value is real but unshipped; 1->1 DREAMING verdict unchanged for today. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/experiments/precompilation-verdict.md | 74 +++++++++++++ .../semantic-platform/Directory.Build.props | 5 + .../Directory.Packages.props | 5 + experiment/semantic-platform/Platform.slnx | 10 ++ .../LoggingConsumerGenerator.cs | 82 ++++++++++++++ .../Qyl.Consumer.Logging/Polyfills.cs | 6 + .../Qyl.Consumer.Logging.csproj | 16 +++ .../OTelConsumerGenerator.cs | 85 ++++++++++++++ .../Qyl.Consumer.OTel/Polyfills.cs | 6 + .../Qyl.Consumer.OTel.csproj | 18 +++ .../Qyl.Platform.Fixture/Domain.cs | 11 ++ .../Qyl.Platform.Fixture/Program.cs | 21 ++++ .../Qyl.Platform.Fixture.csproj | 24 ++++ .../semantic-contract.tsv | 7 ++ .../Qyl.Semantic.Producer/Polyfills.cs | 6 + .../Qyl.Semantic.Producer.csproj | 16 +++ .../SemanticContractProducer.cs | 104 ++++++++++++++++++ experiment/semantic-platform/nuget.config | 8 ++ 18 files changed, 504 insertions(+) create mode 100644 experiment/semantic-platform/Directory.Build.props create mode 100644 experiment/semantic-platform/Directory.Packages.props create mode 100644 experiment/semantic-platform/Platform.slnx create mode 100644 experiment/semantic-platform/Qyl.Consumer.Logging/LoggingConsumerGenerator.cs create mode 100644 experiment/semantic-platform/Qyl.Consumer.Logging/Polyfills.cs create mode 100644 experiment/semantic-platform/Qyl.Consumer.Logging/Qyl.Consumer.Logging.csproj create mode 100644 experiment/semantic-platform/Qyl.Consumer.OTel/OTelConsumerGenerator.cs create mode 100644 experiment/semantic-platform/Qyl.Consumer.OTel/Polyfills.cs create mode 100644 experiment/semantic-platform/Qyl.Consumer.OTel/Qyl.Consumer.OTel.csproj create mode 100644 experiment/semantic-platform/Qyl.Platform.Fixture/Domain.cs create mode 100644 experiment/semantic-platform/Qyl.Platform.Fixture/Program.cs create mode 100644 experiment/semantic-platform/Qyl.Platform.Fixture/Qyl.Platform.Fixture.csproj create mode 100644 experiment/semantic-platform/Qyl.Platform.Fixture/semantic-contract.tsv create mode 100644 experiment/semantic-platform/Qyl.Semantic.Producer/Polyfills.cs create mode 100644 experiment/semantic-platform/Qyl.Semantic.Producer/Qyl.Semantic.Producer.csproj create mode 100644 experiment/semantic-platform/Qyl.Semantic.Producer/SemanticContractProducer.cs create mode 100644 experiment/semantic-platform/nuget.config diff --git a/docs/experiments/precompilation-verdict.md b/docs/experiments/precompilation-verdict.md index b1840e51..e4471945 100644 --- a/docs/experiments/precompilation-verdict.md +++ b/docs/experiments/precompilation-verdict.md @@ -185,3 +185,77 @@ feature — **not** behind the experimental pre-compilation API. "AOT improved" was explicitly not pursued and is not claimed. By the mission's own definition, with generator complexity rising and the API buying nothing for qyl's single-consumer contract, this is DREAMING with a documented, independently-shippable coverage idea — not GENIUS. + +--- + +## Addendum: the 1→N reframe (composition, not AOT) — `experiment/semantic-platform/` + +The verdict above evaluated **today's Qyl: 1 producer → 1 consumer**. A second framing was raised: +*future Qyl as a semantic platform* — `1 producer → N consumers` (OTel, Logging, Metrics, Validation, +Audit, OpenAPI, …) composing over a shared, compiler-visible semantic contract. In that graph the +experimental API's value is **composition** (cross-generator type visibility), not AOT. That framing +is legitimate and changes the scope of the verdict, so I built it. + +### Built and proven +`experiment/semantic-platform/`: one **producer** (`SemanticContractProducer`) reads a domain +`semantic-contract.tsv` and, via `RegisterPreCompilationSourceOutput`, emits the shared +`[QylSemanticBinding]` attribute + attribute-decorated `partial`s of the user's types into the +**initial** compilation. Two **independent consumers** — `OTelConsumerGenerator`, +`LoggingConsumerGenerator` — **do not reference the producer**; each binds the contract through the +shared compilation in its standard phase and projects it differently: +``` +OTel consumer (keyed by semconv attribute): activity.SetTag("customer.id", c-1) ... +Logging consumer (keyed by property name): scope["CustomerId"] = c-1 ... +1 producer (pre-compilation contract) -> 2 consumers, neither referencing the producer. +``` +Green build + that output = cross-generator composition works on the merged API. + +### Why this genuinely needs #83088 (not post-init) +`RegisterPostInitializationOutput` also lands source in the initial compilation visible to other +generators — but it takes **no inputs**, so it cannot read `semantic-contract.tsv`. The contract here +is **data-driven from an additional file**, which is exactly `RegisterPreCompilationSourceOutput`'s +narrow sweet spot (the design doc's "Configurable Post-Initialization", #53632). So the composition +demo is a real, non-trivial use of #83088. + +### The wall, stated precisely (and confirmed empirically) +The compelling version of the vision is **DTO inference → shared contract → N consumers**. It does +**not** work on the merged API, because the pre-compilation phase may not read the compilation/syntax +(`InvalidOperationException`) — so it cannot inspect DTOs. Publishing a **DTO-derived** contract +cross-generator in one compile is the job of the *other* proposal, `RegisterDeclarationOutput` +(#81395), which **is not merged**: grepping the nightly `Microsoft.CodeAnalysis.dll` gives +`RegisterDeclarationOutput` count **0** vs `RegisterPreCompilationSourceOutput` count **1**. The +platform diagram ("Semantic Model Enrichment → Compilation → Many Generators") is #81395's model. + +### Three composition patterns, by what is buildable today +| Pattern | Shared contract derived from | Cross-gen visibility via | Today | +|---|---|---|---| +| **A** | user-authored `[QylSemanticBinding]` annotations (a codefix can apply the inference result) | already in user code — every generator sees it | ✅ no experimental API | +| **B** (built here) | additional-file / config | `RegisterPreCompilationSourceOutput` (#83088) | ✅ nightly compiler | +| **C** (the literal vision) | **DTO inference** | `RegisterDeclarationOutput` (#81395) | ❌ unmerged (count 0) | + +### Revised verdict, scoped to the platform framing +- The **composition value is real and demonstrated** — for contracts derived from additional files / + config (Pattern B). For that class, `RegisterPreCompilationSourceOutput` is the right tool, and a + *hypothetical* multi-consumer Qyl is where it would earn its place. This reframing makes the + experiment **interesting rather than pointless** — but not *shippable*: the API is still + unshipped/main-only (`RSEXPERIMENTAL007`), so "rehabilitated" means "has a real use case under a + future architecture", not "ready to adopt". The single-consumer DREAMING verdict is unchanged for + today's Qyl. + +> **Known limitations of the demo (scope, not hidden bugs, surfaced by adversarial review):** the +> producer addresses **top-level** types only (a dotted FQN cannot encode nesting); the two consumers +> subscribe to `CompilationProvider` directly — the same re-run-per-edit pattern the repo's own +> `SemConvRegistryGenerator` uses, fine for a demo but not for scale; emitted `value.` access +> assumes public, existing properties. +- The **standard-phase DTO inference is a real, shippable generator** (proven in + `experiment/contract-precompilation/`: live DTO inspection → bindings, no experimental API). +- But the **headline vision — DTO inference feeding N generators in one compile — is blocked on an + unmerged API (#81395), not unlocked by #83088.** Today that vision is reachable only via Pattern A + (materialize the inferred contract as user-visible annotations; the inference generator becomes an + analyzer + codefix), which needs no experimental API at all. + +Net: the original `1→1` DREAMING verdict stands for *today's* Qyl. The `1→N` reframe is sound and the +composition is demonstrated — but the specific, most-exciting capability (compile-time DTO inference +shared across many generators) waits on `RegisterDeclarationOutput`, while everything achievable today +is achievable **without** the experimental pre-compilation API (Pattern A) or with it only for +additional-file-derived contracts (Pattern B). diff --git a/experiment/semantic-platform/Directory.Build.props b/experiment/semantic-platform/Directory.Build.props new file mode 100644 index 00000000..be9f949e --- /dev/null +++ b/experiment/semantic-platform/Directory.Build.props @@ -0,0 +1,5 @@ + + + diff --git a/experiment/semantic-platform/Directory.Packages.props b/experiment/semantic-platform/Directory.Packages.props new file mode 100644 index 00000000..5f9708a9 --- /dev/null +++ b/experiment/semantic-platform/Directory.Packages.props @@ -0,0 +1,5 @@ + + + false + + diff --git a/experiment/semantic-platform/Platform.slnx b/experiment/semantic-platform/Platform.slnx new file mode 100644 index 00000000..85d860e2 --- /dev/null +++ b/experiment/semantic-platform/Platform.slnx @@ -0,0 +1,10 @@ + + + + + + + diff --git a/experiment/semantic-platform/Qyl.Consumer.Logging/LoggingConsumerGenerator.cs b/experiment/semantic-platform/Qyl.Consumer.Logging/LoggingConsumerGenerator.cs new file mode 100644 index 00000000..cbdb07ae --- /dev/null +++ b/experiment/semantic-platform/Qyl.Consumer.Logging/LoggingConsumerGenerator.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Qyl.Consumer.Logging; + +/// +/// CONSUMER #2 of the 1→N platform. Independent of the producer and of consumer #1. Binds the same +/// shared contract (Qyl.Semantic.QylSemanticBindingAttribute) and projects it the structured- +/// logging way: scope fields keyed by the PROPERTY name (not the semconv attribute). Same contract, +/// divergent consumption — the point of the semantic-platform architecture. +/// +[Generator(LanguageNames.CSharp)] +public sealed class LoggingConsumerGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterSourceOutput(context.CompilationProvider, static (spc, compilation) => + { + var marker = compilation.GetTypeByMetadataName("Qyl.Semantic.QylSemanticBindingAttribute"); + if (marker is null) return; + + var bound = Collect(compilation, marker); + if (bound.Count == 0) return; + + var b = new StringBuilder(); + b.AppendLine("// Logging consumer — bound to the shared semantic contract."); + b.AppendLine("#nullable enable"); + b.AppendLine("namespace Qyl.Generated.Logging;"); + b.AppendLine("public static class LogScopeFields"); + b.AppendLine("{"); + foreach (var t in bound) + { + b.AppendLine($" /// Structured-logging scope fields for (keyed by property)."); + b.AppendLine($" public static global::System.Collections.Generic.KeyValuePair[] For(global::{t.Fqn} value) => new global::System.Collections.Generic.KeyValuePair[]"); + b.AppendLine(" {"); + foreach (var (prop, _) in t.Bindings) + b.AppendLine($" new(\"{prop}\", value.{prop}),"); + b.AppendLine(" };"); + } + b.AppendLine("}"); + spc.AddSource("LogScopeFields.g.cs", SourceText.From(b.ToString(), Encoding.UTF8)); + }); + } + + private readonly record struct Bound(string Fqn, List<(string Property, string Attribute)> Bindings); + + private static List Collect(Compilation compilation, INamedTypeSymbol marker) + { + var result = new List(); + foreach (var type in EnumerateSourceTypes(compilation.GlobalNamespace)) + { + var bindings = new List<(string, string)>(); + foreach (var a in type.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(a.AttributeClass, marker) && + a.ConstructorArguments.Length == 2 && + a.ConstructorArguments[0].Value is string prop && + a.ConstructorArguments[1].Value is string attr) + { + bindings.Add((prop, attr)); + } + } + if (bindings.Count > 0) + result.Add(new Bound(type.ToDisplayString(), bindings.OrderBy(x => x.Item1, StringComparer.Ordinal).ToList())); + } + return result.OrderBy(x => x.Fqn, StringComparer.Ordinal).ToList(); + } + + private static IEnumerable EnumerateSourceTypes(INamespaceSymbol ns) + { + foreach (var t in ns.GetTypeMembers()) + if (t.Locations.Any(l => l.IsInSource)) + yield return t; + foreach (var child in ns.GetNamespaceMembers()) + foreach (var t in EnumerateSourceTypes(child)) + yield return t; + } +} diff --git a/experiment/semantic-platform/Qyl.Consumer.Logging/Polyfills.cs b/experiment/semantic-platform/Qyl.Consumer.Logging/Polyfills.cs new file mode 100644 index 00000000..617ad977 --- /dev/null +++ b/experiment/semantic-platform/Qyl.Consumer.Logging/Polyfills.cs @@ -0,0 +1,6 @@ +// netstandard2.0 has no IsExternalInit; records/init require it. The production generator gets this +// from ANcpLua.Roslyn.Utilities.Sources — the isolated experiment supplies it directly. +namespace System.Runtime.CompilerServices +{ + internal static class IsExternalInit { } +} diff --git a/experiment/semantic-platform/Qyl.Consumer.Logging/Qyl.Consumer.Logging.csproj b/experiment/semantic-platform/Qyl.Consumer.Logging/Qyl.Consumer.Logging.csproj new file mode 100644 index 00000000..205203ae --- /dev/null +++ b/experiment/semantic-platform/Qyl.Consumer.Logging/Qyl.Consumer.Logging.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.0 + latest + enable + true + true + $(NoWarn);RS2008 + + + + + + + diff --git a/experiment/semantic-platform/Qyl.Consumer.OTel/OTelConsumerGenerator.cs b/experiment/semantic-platform/Qyl.Consumer.OTel/OTelConsumerGenerator.cs new file mode 100644 index 00000000..a42dc641 --- /dev/null +++ b/experiment/semantic-platform/Qyl.Consumer.OTel/OTelConsumerGenerator.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Qyl.Consumer.OTel; + +/// +/// CONSUMER #1 of the 1→N platform. Does NOT reference the producer. In its standard phase it binds +/// the producer's pre-compilation contract (Qyl.Semantic.QylSemanticBindingAttribute, visible +/// because it was emitted into the initial compilation) and projects it the OTel way: tags keyed by +/// the semantic-convention attribute (customer.id). +/// +[Generator(LanguageNames.CSharp)] +public sealed class OTelConsumerGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterSourceOutput(context.CompilationProvider, static (spc, compilation) => + { + var marker = compilation.GetTypeByMetadataName("Qyl.Semantic.QylSemanticBindingAttribute"); + if (marker is null) return; + + var bound = ContractReader.Collect(compilation, marker); + if (bound.Count == 0) return; + + var b = new StringBuilder(); + b.AppendLine("// OTel consumer — bound to the shared semantic contract."); + b.AppendLine("#nullable enable"); + b.AppendLine("namespace Qyl.Generated.OTel;"); + b.AppendLine("public static class OTelSemanticTags"); + b.AppendLine("{"); + foreach (var t in bound) + { + b.AppendLine($" /// OTel attribute tags for (keyed by semconv attribute)."); + b.AppendLine($" public static global::System.Collections.Generic.KeyValuePair[] For(global::{t.TypeFqn} value) => new global::System.Collections.Generic.KeyValuePair[]"); + b.AppendLine(" {"); + foreach (var (prop, attr) in t.Bindings) + b.AppendLine($" new(\"{attr}\", value.{prop}),"); + b.AppendLine(" };"); + } + b.AppendLine("}"); + spc.AddSource("OTelSemanticTags.g.cs", SourceText.From(b.ToString(), Encoding.UTF8)); + }); + } +} + +internal static class ContractReader +{ + public readonly record struct BoundType(string TypeFqn, IReadOnlyList<(string Property, string Attribute)> Bindings); + + public static List Collect(Compilation compilation, INamedTypeSymbol marker) + { + var result = new List(); + foreach (var type in EnumerateSourceTypes(compilation.GlobalNamespace)) + { + var bindings = new List<(string, string)>(); + foreach (var a in type.GetAttributes()) + { + if (!SymbolEqualityComparer.Default.Equals(a.AttributeClass, marker)) continue; + if (a.ConstructorArguments.Length == 2 && + a.ConstructorArguments[0].Value is string prop && + a.ConstructorArguments[1].Value is string attr) + { + bindings.Add((prop, attr)); + } + } + if (bindings.Count > 0) + result.Add(new BoundType(type.ToDisplayString(), bindings.OrderBy(x => x.Item1, StringComparer.Ordinal).ToList())); + } + return result.OrderBy(x => x.TypeFqn, StringComparer.Ordinal).ToList(); + } + + private static IEnumerable EnumerateSourceTypes(INamespaceSymbol ns) + { + foreach (var t in ns.GetTypeMembers()) + if (t.Locations.Any(l => l.IsInSource)) + yield return t; + foreach (var child in ns.GetNamespaceMembers()) + foreach (var t in EnumerateSourceTypes(child)) + yield return t; + } +} diff --git a/experiment/semantic-platform/Qyl.Consumer.OTel/Polyfills.cs b/experiment/semantic-platform/Qyl.Consumer.OTel/Polyfills.cs new file mode 100644 index 00000000..617ad977 --- /dev/null +++ b/experiment/semantic-platform/Qyl.Consumer.OTel/Polyfills.cs @@ -0,0 +1,6 @@ +// netstandard2.0 has no IsExternalInit; records/init require it. The production generator gets this +// from ANcpLua.Roslyn.Utilities.Sources — the isolated experiment supplies it directly. +namespace System.Runtime.CompilerServices +{ + internal static class IsExternalInit { } +} diff --git a/experiment/semantic-platform/Qyl.Consumer.OTel/Qyl.Consumer.OTel.csproj b/experiment/semantic-platform/Qyl.Consumer.OTel/Qyl.Consumer.OTel.csproj new file mode 100644 index 00000000..89f9355f --- /dev/null +++ b/experiment/semantic-platform/Qyl.Consumer.OTel/Qyl.Consumer.OTel.csproj @@ -0,0 +1,18 @@ + + + + netstandard2.0 + latest + enable + true + true + $(NoWarn);RS2008 + + + + + + + + diff --git a/experiment/semantic-platform/Qyl.Platform.Fixture/Domain.cs b/experiment/semantic-platform/Qyl.Platform.Fixture/Domain.cs new file mode 100644 index 00000000..e5b56bd3 --- /dev/null +++ b/experiment/semantic-platform/Qyl.Platform.Fixture/Domain.cs @@ -0,0 +1,11 @@ +namespace Qyl.Platform.Fixture.Domain; + +// User DTO. Declared partial so the producer's pre-compilation contract part (which carries the +// [QylSemanticBinding] attributes) merges into this same type. The user writes no attributes here. +public partial class CreateOrderRequest +{ + public string CustomerId { get; init; } = ""; + public string OrderId { get; init; } = ""; + public string TenantId { get; init; } = ""; + public decimal Amount { get; init; } +} diff --git a/experiment/semantic-platform/Qyl.Platform.Fixture/Program.cs b/experiment/semantic-platform/Qyl.Platform.Fixture/Program.cs new file mode 100644 index 00000000..4ac8a276 --- /dev/null +++ b/experiment/semantic-platform/Qyl.Platform.Fixture/Program.cs @@ -0,0 +1,21 @@ +using Qyl.Generated.Logging; +using Qyl.Generated.OTel; +using Qyl.Platform.Fixture.Domain; + +// OTelSemanticTags and LogScopeFields are produced by two INDEPENDENT consumer generators, neither +// of which references the producer. Both bound to the producer's pre-compilation [QylSemanticBinding] +// contract via the shared compilation. A green build that calls both is proof of 1-producer→N-consumer +// composition over a shared semantic graph. + +var order = new CreateOrderRequest { CustomerId = "c-1", OrderId = "o-9", TenantId = "t-7", Amount = 42m }; + +Console.WriteLine("OTel consumer (keyed by semantic-convention attribute):"); +foreach (var kv in OTelSemanticTags.For(order)) + Console.WriteLine($" activity.SetTag(\"{kv.Key}\", {kv.Value})"); + +Console.WriteLine("Logging consumer (keyed by property name — divergent projection of the SAME contract):"); +foreach (var kv in LogScopeFields.For(order)) + Console.WriteLine($" scope[\"{kv.Key}\"] = {kv.Value}"); + +Console.WriteLine(); +Console.WriteLine("PLATFORM: 1 producer (pre-compilation contract) -> 2 consumers, neither referencing the producer."); diff --git a/experiment/semantic-platform/Qyl.Platform.Fixture/Qyl.Platform.Fixture.csproj b/experiment/semantic-platform/Qyl.Platform.Fixture/Qyl.Platform.Fixture.csproj new file mode 100644 index 00000000..dde216ed --- /dev/null +++ b/experiment/semantic-platform/Qyl.Platform.Fixture/Qyl.Platform.Fixture.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + latest + enable + enable + + + + + + + + + + + + + + + diff --git a/experiment/semantic-platform/Qyl.Platform.Fixture/semantic-contract.tsv b/experiment/semantic-platform/Qyl.Platform.Fixture/semantic-contract.tsv new file mode 100644 index 00000000..970bb081 --- /dev/null +++ b/experiment/semantic-platform/Qyl.Platform.Fixture/semantic-contract.tsv @@ -0,0 +1,7 @@ +# Fully-qualified type Property semantic-convention attribute +# Domain semantic contract (the inference RESULT, materialized as data). A standard-phase analyzer +# can generate this file from DTO property names × the seed index; the producer publishes it +# cross-generator via RegisterPreCompilationSourceOutput. +Qyl.Platform.Fixture.Domain.CreateOrderRequest CustomerId customer.id +Qyl.Platform.Fixture.Domain.CreateOrderRequest OrderId order.id +Qyl.Platform.Fixture.Domain.CreateOrderRequest TenantId tenant.id diff --git a/experiment/semantic-platform/Qyl.Semantic.Producer/Polyfills.cs b/experiment/semantic-platform/Qyl.Semantic.Producer/Polyfills.cs new file mode 100644 index 00000000..617ad977 --- /dev/null +++ b/experiment/semantic-platform/Qyl.Semantic.Producer/Polyfills.cs @@ -0,0 +1,6 @@ +// netstandard2.0 has no IsExternalInit; records/init require it. The production generator gets this +// from ANcpLua.Roslyn.Utilities.Sources — the isolated experiment supplies it directly. +namespace System.Runtime.CompilerServices +{ + internal static class IsExternalInit { } +} diff --git a/experiment/semantic-platform/Qyl.Semantic.Producer/Qyl.Semantic.Producer.csproj b/experiment/semantic-platform/Qyl.Semantic.Producer/Qyl.Semantic.Producer.csproj new file mode 100644 index 00000000..a8f4a861 --- /dev/null +++ b/experiment/semantic-platform/Qyl.Semantic.Producer/Qyl.Semantic.Producer.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.0 + latest + enable + true + true + $(NoWarn);RSEXPERIMENTAL007;RS2008 + + + + + + + diff --git a/experiment/semantic-platform/Qyl.Semantic.Producer/SemanticContractProducer.cs b/experiment/semantic-platform/Qyl.Semantic.Producer/SemanticContractProducer.cs new file mode 100644 index 00000000..6db6bd21 --- /dev/null +++ b/experiment/semantic-platform/Qyl.Semantic.Producer/SemanticContractProducer.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Qyl.Semantic.Producer; + +/// +/// PRODUCER (1 of the 1→N platform). Reads a domain semantic-contract additional file and emits the +/// shared contract into the INITIAL compilation via RegisterPreCompilationSourceOutput (#83088): +/// - the shared attribute type Qyl.Semantic.QylSemanticBindingAttribute, and +/// - for each contract row, an attribute-decorated partial of the user's type. +/// Because this is pre-compilation output, it is visible to EVERY other generator's standard phase — +/// so consumers (OTel, Logging, …) bind to it WITHOUT referencing this producer. +/// +/// Why an additional file and not DTO inference: the pre-compilation phase may not touch the +/// compilation/syntax (it throws). DTO-name inference therefore runs as a standard-phase analyzer +/// that PRODUCES this contract file; publishing a DTO-derived contract cross-generator in one pass +/// would need RegisterDeclarationOutput (#81395), which is unmerged. See the platform verdict. +/// +[Generator(LanguageNames.CSharp)] +public sealed class SemanticContractProducer : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var contract = context.AdditionalTextsProvider + .Where(static t => t.Path.Replace('\\', '/').EndsWith("semantic-contract.tsv", StringComparison.OrdinalIgnoreCase)) + .Select(static (t, ct) => t.GetText(ct)?.ToString() ?? string.Empty) + .Collect(); + + context.RegisterPreCompilationSourceOutput(contract, static (spc, texts) => + { + spc.AddSource("QylSemanticBindingAttribute.g.cs", SourceText.From(AttributeSource, Encoding.UTF8)); + + var rows = Parse(string.Concat(texts)); + foreach (var typeGroup in rows.GroupBy(r => r.TypeFqn, StringComparer.Ordinal)) + spc.AddSource(HintFor(typeGroup.Key), SourceText.From(BuildPartial(typeGroup.Key, typeGroup.ToList()), Encoding.UTF8)); + }); + } + + private const string AttributeSource = +""" +// shared semantic-contract vocabulary, emitted into the initial compilation. +#nullable enable +namespace Qyl.Semantic; + +/// One inferred semantic-convention binding: a property of the annotated type maps to an +/// OpenTelemetry attribute key. Read by any consumer generator via the semantic model. +[global::System.AttributeUsage(global::System.AttributeTargets.Class, AllowMultiple = true, Inherited = false)] +public sealed class QylSemanticBindingAttribute : global::System.Attribute +{ + public QylSemanticBindingAttribute(string property, string attribute) + { + Property = property; + Attribute = attribute; + } + + public string Property { get; } + public string Attribute { get; } +} +"""; + + private readonly record struct Row(string TypeFqn, string Property, string Attribute); + + private static List Parse(string tsv) + { + var rows = new List(); + foreach (var raw in tsv.Split('\n')) + { + var line = raw.TrimEnd('\r'); + if (line.Length == 0 || line[0] == '#') continue; + var c = line.Split('\t'); + if (c.Length < 3) continue; + rows.Add(new Row(c[0].Trim(), c[1].Trim(), c[2].Trim())); + } + return rows; + } + + private static string HintFor(string typeFqn) => typeFqn.Replace('.', '_') + ".Contract.g.cs"; + + private static string BuildPartial(string typeFqn, List rows) + { + // DEMO SCOPE: the contract FQN column addresses TOP-LEVEL types (Namespace.Type). Nested + // types (metadata 'Outer+Inner') are out of scope — a dotted split cannot distinguish a + // namespace segment from an outer-type segment, so they would silently mis-target. A + // production version would carry the containing-type chain explicitly. + var lastDot = typeFqn.LastIndexOf('.'); + var ns = lastDot < 0 ? null : typeFqn.Substring(0, lastDot); + var name = lastDot < 0 ? typeFqn : typeFqn.Substring(lastDot + 1); + + var b = new StringBuilder(); + b.AppendLine("// compiler-visible semantic contract (pre-compilation output)."); + b.AppendLine("#nullable enable"); + if (ns is not null) b.AppendLine($"namespace {ns};").AppendLine(); + foreach (var r in rows) + b.AppendLine($"[global::Qyl.Semantic.QylSemanticBindingAttribute(\"{Escape(r.Property)}\", \"{Escape(r.Attribute)}\")]"); + b.AppendLine($"public partial class {name} {{ }}"); + return b.ToString(); + } + + private static string Escape(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\""); +} diff --git a/experiment/semantic-platform/nuget.config b/experiment/semantic-platform/nuget.config new file mode 100644 index 00000000..131303b8 --- /dev/null +++ b/experiment/semantic-platform/nuget.config @@ -0,0 +1,8 @@ + + + + + + + + From 9e87b1ba5bcccf8d32689d350163eb3a0264724a Mon Sep 17 00:00:00 2001 From: ancplua Date: Fri, 26 Jun 2026 02:33:43 +0200 Subject: [PATCH 05/10] experiment(platform): generate AOT-safe telemetry recorder, prove on a live Activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade the OTel consumer to emit the OrderTelemetry.Record(activity, value) shape: pure activity.SetTag from the shared semantic contract — no reflection, no IL rewrite, no profiler. Fixture now drives it against a live System.Diagnostics.Activity (ActivitySource + listener) and prints the tags actually set: customer.id/order.id/tenant.id. Telemetry compiled INTO the app. Standard-phase generation needs NO experimental API (qyl already does this via interceptors); the producer's #83088 contract only supplied the property->attribute mapping cross-generator. Isolated tree; production files untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../OTelConsumerGenerator.cs | 24 +++++++++----- .../Qyl.Platform.Fixture/Program.cs | 33 ++++++++++++++----- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/experiment/semantic-platform/Qyl.Consumer.OTel/OTelConsumerGenerator.cs b/experiment/semantic-platform/Qyl.Consumer.OTel/OTelConsumerGenerator.cs index a42dc641..ca80b21f 100644 --- a/experiment/semantic-platform/Qyl.Consumer.OTel/OTelConsumerGenerator.cs +++ b/experiment/semantic-platform/Qyl.Consumer.OTel/OTelConsumerGenerator.cs @@ -26,23 +26,29 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var bound = ContractReader.Collect(compilation, marker); if (bound.Count == 0) return; + // "Telemetry becomes part of the application": emit a real AOT-safe recorder per bound + // type — pure activity.SetTag, no reflection, no IL rewrite, no profiler. This is ordinary + // standard-phase source generation (it needs NO experimental API); the producer's #83088 + // contract is only how this consumer learned which property maps to which attribute. var b = new StringBuilder(); - b.AppendLine("// OTel consumer — bound to the shared semantic contract."); + b.AppendLine("// OTel consumer — AOT-safe telemetry generated from the shared semantic contract."); b.AppendLine("#nullable enable"); b.AppendLine("namespace Qyl.Generated.OTel;"); - b.AppendLine("public static class OTelSemanticTags"); - b.AppendLine("{"); foreach (var t in bound) { - b.AppendLine($" /// OTel attribute tags for (keyed by semconv attribute)."); - b.AppendLine($" public static global::System.Collections.Generic.KeyValuePair[] For(global::{t.TypeFqn} value) => new global::System.Collections.Generic.KeyValuePair[]"); + var simple = t.TypeFqn.Substring(t.TypeFqn.LastIndexOf('.') + 1); + b.AppendLine($"/// Records OTel semantic-convention tags for . Pure SetTag; no reflection."); + b.AppendLine($"public static class {simple}Telemetry"); + b.AppendLine("{"); + b.AppendLine($" public static void Record(global::System.Diagnostics.Activity? activity, global::{t.TypeFqn} value)"); b.AppendLine(" {"); + b.AppendLine(" if (activity is null) return;"); foreach (var (prop, attr) in t.Bindings) - b.AppendLine($" new(\"{attr}\", value.{prop}),"); - b.AppendLine(" };"); + b.AppendLine($" activity.SetTag(\"{attr}\", value.{prop});"); + b.AppendLine(" }"); + b.AppendLine("}"); } - b.AppendLine("}"); - spc.AddSource("OTelSemanticTags.g.cs", SourceText.From(b.ToString(), Encoding.UTF8)); + spc.AddSource("OTelTelemetry.g.cs", SourceText.From(b.ToString(), Encoding.UTF8)); }); } } diff --git a/experiment/semantic-platform/Qyl.Platform.Fixture/Program.cs b/experiment/semantic-platform/Qyl.Platform.Fixture/Program.cs index 4ac8a276..dee60b74 100644 --- a/experiment/semantic-platform/Qyl.Platform.Fixture/Program.cs +++ b/experiment/semantic-platform/Qyl.Platform.Fixture/Program.cs @@ -1,21 +1,36 @@ +using System.Diagnostics; using Qyl.Generated.Logging; using Qyl.Generated.OTel; using Qyl.Platform.Fixture.Domain; -// OTelSemanticTags and LogScopeFields are produced by two INDEPENDENT consumer generators, neither -// of which references the producer. Both bound to the producer's pre-compilation [QylSemanticBinding] -// contract via the shared compilation. A green build that calls both is proof of 1-producer→N-consumer -// composition over a shared semantic graph. +// CreateOrderRequestTelemetry and LogScopeFields are produced by two INDEPENDENT consumer generators, +// neither of which references the producer; both bound the producer's pre-compilation [QylSemanticBinding] +// contract via the shared compilation. The OTel recorder is pure SetTag — AOT-safe, no reflection. + +using var listener = new ActivityListener +{ + ShouldListenTo = s => s.Name == "Qyl.Demo", + Sample = static (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, +}; +ActivitySource.AddActivityListener(listener); +var source = new ActivitySource("Qyl.Demo"); var order = new CreateOrderRequest { CustomerId = "c-1", OrderId = "o-9", TenantId = "t-7", Amount = 42m }; -Console.WriteLine("OTel consumer (keyed by semantic-convention attribute):"); -foreach (var kv in OTelSemanticTags.For(order)) - Console.WriteLine($" activity.SetTag(\"{kv.Key}\", {kv.Value})"); +using (var activity = source.StartActivity("create-order")) +{ + // Generated telemetry — part of the application binary, not a runtime hook. + CreateOrderRequestTelemetry.Record(activity, order); + + Console.WriteLine("Generated telemetry set these tags on a LIVE System.Diagnostics.Activity:"); + foreach (var tag in activity!.TagObjects) + Console.WriteLine($" {tag.Key} = {tag.Value}"); +} -Console.WriteLine("Logging consumer (keyed by property name — divergent projection of the SAME contract):"); +Console.WriteLine(); +Console.WriteLine("Logging consumer (same contract, projected by property name):"); foreach (var kv in LogScopeFields.For(order)) Console.WriteLine($" scope[\"{kv.Key}\"] = {kv.Value}"); Console.WriteLine(); -Console.WriteLine("PLATFORM: 1 producer (pre-compilation contract) -> 2 consumers, neither referencing the producer."); +Console.WriteLine("PLATFORM: 1 producer (pre-compilation contract) -> N consumers; telemetry is compiled INTO the app."); From c5a5ee99c3143cbd2249c0261587cb7c17afd65c Mon Sep 17 00:00:00 2001 From: ancplua Date: Fri, 26 Jun 2026 03:09:41 +0200 Subject: [PATCH 06/10] refactor(generator): bind implemented-signal gate from pre-compilation QylContractRegistry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Productize the in-place precompilation refactor: QylAutoInstrumentationGenerator emits the implemented-signal key set as the QylContractRegistry symbol via RegisterPreCompilationSourceOutput, binds it back in the standard phase (GetTypeByMetadataName), and applies the gate in EmitInterceptors. TryGet* in-process lookups deleted from InstrumentationContract (ImplementedSignalKeys is the source). Toolchain forced to the Roslyn 5.9.0 nightly repo-wide (dnceng feed + source mapping) since the API is main-only (RSEXPERIMENTAL007) — the non-shippable cost, accepted for dogfood. Updated verify-contract-invariants.py to assert the new mechanism (RegisterPreCompilationSourceOutput / QylContractRegistry / GetTypeByMetadataName) instead of the deleted TryGet* helpers — the prior green was passing only on the deletion comment naming the methods. Validated green: SourceGenerators (0/0), core runtime, snapshot fixture; verify-generator-snapshots (interceptor output unchanged — behavior-preserving) and verify-contract-invariants both pass. Full demo/AOT/container gate not yet re-run under the nightly. Co-Authored-By: Claude Opus 4.8 (1M context) --- Directory.Build.props | 5 ++ Directory.Packages.props | 10 ++- nuget.config | 18 +++++ .../InstrumentationContract.cs | 29 ++------ ...utoInstrumentation.SourceGenerators.csproj | 3 + .../QylAutoInstrumentationGenerator.cs | 66 ++++++++++++++++--- tools/verify-contract-invariants.py | 8 ++- 7 files changed, 102 insertions(+), 37 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index f34a395a..71d172d1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -58,6 +58,11 @@ Pack="true" PackagePath="" Visible="false" /> + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 0e81ca48..7eac26ef 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,8 +20,14 @@ - - + + + + + diff --git a/nuget.config b/nuget.config index 765346e5..19b8485b 100644 --- a/nuget.config +++ b/nuget.config @@ -3,5 +3,23 @@ + + + + + + + + + + + + + + diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/InstrumentationContract.cs b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/InstrumentationContract.cs index 885bf917..6f23c340 100644 --- a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/InstrumentationContract.cs +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/InstrumentationContract.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -316,30 +315,10 @@ public static string EmitGeneratedManifestSource() return builder.ToString(); } - public static InstrumentationContractItem? TryGetImplementedSignal(string key) - => TryGetSignal(key, static item => item.QylStatus is QylContractStatus.Implemented); - - public static InstrumentationContractItem? TryGetSourceInterceptorSignal(string key) - => TryGetSignal(key, static item => item is { QylStatus: QylContractStatus.Implemented, Lane: InstrumentationContractLane.SourceInterceptor }); - - public static InstrumentationContractItem? TryGetSupportedSignal(string key) - => TryGetSignal(key, static _ => true); - - private static InstrumentationContractItem? TryGetSignal(string key, Func predicate) - { - foreach (var item in Items) - { - if (item.Kind is InstrumentationContractKind.SignalSpecificInstrumentationPromise && - predicate(item) && - string.Equals(item.Key, key, StringComparison.Ordinal)) - { - return item; - } - } - - return null; - } - + // The implemented-signal gate is no longer an in-process lookup here. It is emitted as the + // pre-compilation QylContractRegistry symbol (RegisterPreCompilationSourceOutput) and bound back + // in the standard phase; see QylAutoInstrumentationGenerator. ImplementedSignalKeys above is its + // single source. private static void EmitStringArray(StringBuilder builder, string name, IEnumerable values) { builder.Append(" public static string[] "); diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.csproj b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.csproj index bdcf1302..2ffba5e1 100644 --- a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.csproj +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.csproj @@ -13,6 +13,9 @@ true true $(WarningsAsErrors);CS1591 + + $(NoWarn);RSEXPERIMENTAL007;RS2008 diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylAutoInstrumentationGenerator.cs b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylAutoInstrumentationGenerator.cs index c64905aa..d7787611 100644 --- a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylAutoInstrumentationGenerator.cs +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylAutoInstrumentationGenerator.cs @@ -72,6 +72,33 @@ public void Initialize(IncrementalGeneratorInitializationContext context) SourceText.From(InstrumentationContract.EmitGeneratedManifestSource(), Encoding.UTF8)); }); + // EXPERIMENTAL (RSEXPERIMENTAL007): emit the implemented-signal contract gate as a + // compilation-native symbol in the PRE-COMPILATION phase, then bind it back in the standard + // phase. Replaces the in-process InstrumentationContract.TryGetImplementedSignal lookup. + context.RegisterPreCompilationSourceOutput( + context.AdditionalTextsProvider.Collect(), + static (precompilation, _) => precompilation.AddSource( + "QylContractRegistry.g.cs", + SourceText.From(EmitContractRegistrySource(), Encoding.UTF8))); + + // Bind the pre-compilation symbol from the augmented compilation; the compilation is the + // cache. A newline-joined string keeps the pipeline value equatable for incremental reuse. + var implementedContractKeys = context.CompilationProvider.Select(static (compilation, _) => + { + var registry = compilation.GetTypeByMetadataName( + "Qyl.OpenTelemetry.AutoInstrumentation.Generated.QylContractRegistry"); + if (registry is null) + return string.Empty; + + return string.Join( + "\n", + registry.GetMembers() + .OfType() + .Where(static field => field.HasConstantValue && field.ConstantValue is string) + .Select(static field => (string)field.ConstantValue!) + .OrderBy(static key => key, StringComparer.Ordinal)); + }); + var interceptedInvocations = context.SyntaxProvider .CreateSyntaxProvider( static (node, _) => node is InvocationExpressionSyntax, @@ -79,7 +106,24 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Where(static invocation => invocation is not null) .Collect(); - context.RegisterSourceOutput(interceptedInvocations, EmitInterceptors); + context.RegisterSourceOutput(interceptedInvocations.Combine(implementedContractKeys), EmitInterceptors); + } + + private static string EmitContractRegistrySource() + { + var builder = new StringBuilder(); + builder.AppendLine("// EXPERIMENTAL pre-compilation phase (RSEXPERIMENTAL007)."); + builder.AppendLine("namespace Qyl.OpenTelemetry.AutoInstrumentation.Generated;"); + builder.AppendLine("internal static class QylContractRegistry"); + builder.AppendLine("{"); + + var index = 0; + foreach (var key in InstrumentationContract.ImplementedSignalKeys) + builder.AppendLine($" public const string ImplementedKey{index++} = \"{key}\";"); + + builder.AppendLine($" public const int ImplementedKeyCount = {InstrumentationContract.ImplementedSignalKeys.Length};"); + builder.AppendLine("}"); + return builder.ToString(); } private static InterceptedInvocation? TryCreateInterceptedInvocation( @@ -101,9 +145,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (!TryGetInvocation(symbol, receiverType, out var target)) return null; - if (!IsSupportedTarget(target)) - return null; - + // Contract gating moved to emission time (EmitInterceptors), where the pre-compilation + // QylContractRegistry symbol is bound. Every structurally-matched target now flows through + // the pipeline; the contract filter is applied once the bound key set is available. var interceptableLocation = context.SemanticModel.GetInterceptableLocation(invocation, cancellationToken); if (interceptableLocation is null) return null; @@ -111,9 +155,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return new InterceptedInvocation(target, interceptableLocation); } - private static bool IsSupportedTarget(in InterceptorTarget target) + private static bool IsSupportedTarget(in InterceptorTarget target, HashSet implementedKeys) { - if (InstrumentationContract.TryGetImplementedSignal(target.ContractKey) is null) + if (!implementedKeys.Contains(target.ContractKey)) return false; if (target.AdditionalContractKeys is not { Length: > 0 } additionalContractKeys) @@ -121,7 +165,7 @@ private static bool IsSupportedTarget(in InterceptorTarget target) foreach (var contractKey in additionalContractKeys) { - if (InstrumentationContract.TryGetImplementedSignal(contractKey) is null) + if (!implementedKeys.Contains(contractKey)) return false; } @@ -197,14 +241,20 @@ private static void EnsureContractDeclaredByMatcher( private static void EmitInterceptors( SourceProductionContext context, - ImmutableArray nullableInvocations) + (ImmutableArray Invocations, string ImplementedKeysJoined) input) { + var nullableInvocations = input.Invocations; if (nullableInvocations.IsDefaultOrEmpty) return; + var implementedKeys = input.ImplementedKeysJoined.Length is 0 + ? new HashSet(StringComparer.Ordinal) + : new HashSet(input.ImplementedKeysJoined.Split('\n'), StringComparer.Ordinal); + var invocations = nullableInvocations .Where(static invocation => invocation is not null) .Select(static invocation => invocation!.Value) + .Where(invocation => IsSupportedTarget(invocation.Target, implementedKeys)) .Distinct() // Stable, content-based ordering so the emission order and the _N interceptor-name indices // are a pure function of the matched call sites — independent of Roslyn's cross-tree syntax diff --git a/tools/verify-contract-invariants.py b/tools/verify-contract-invariants.py index 214e07d2..7fb56642 100644 --- a/tools/verify-contract-invariants.py +++ b/tools/verify-contract-invariants.py @@ -1797,8 +1797,12 @@ def verify_generator_keys(artifacts: ModuleType, contract: dict[str, Any]) -> No "SourceInterceptorSignalKeys", "RuntimePublicTelemetrySignalKeys", "UnsupportedNativeAotSignalKeys", - "TryGetSourceInterceptorSignal", - "TryGetImplementedSignal", + # The implemented-signal gate is no longer an in-process TryGet* lookup; it is emitted as the + # pre-compilation QylContractRegistry symbol and bound back in the standard phase. Assert the + # mechanism (in the generator) instead of the deleted helpers. + "RegisterPreCompilationSourceOutput", + "QylContractRegistry", + "GetTypeByMetadataName", "InterceptorEmissionDescriptor", "InterceptorMethodShape", "InterceptorSignalOwnership", From a01317119239c6db2a6ffed0fd1394c37a93072c Mon Sep 17 00:00:00 2001 From: ancplua Date: Fri, 26 Jun 2026 03:20:08 +0200 Subject: [PATCH 07/10] feat(generator): semantic-contract producer + AOT-safe telemetry consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the semantic-platform pair into the production SourceGenerators project: - QylSemanticContractProducer: reads *.qyl-semantic-contract.tsv, pre-comp-emits the shared [QylSemanticBinding] attribute + attribute-decorated partials of user types into the initial compilation (RegisterPreCompilationSourceOutput), so any generator can bind the contract. - QylSemanticTelemetryGenerator: first consumer — binds the contract in its standard phase and emits per-type {Type}Telemetry.Record(Activity?, value) = pure SetTag, no reflection, AOT-safe. Both inert without a contract file, so the existing demos/packages are unaffected (verified: the snapshot fixture generates nothing new until opted in). Fixture opts in via OrderRequest + app.qyl-semantic-contract.tsv; 3 new verified snapshots added; verify-generator-snapshots and verify-contract-invariants both green. Within qyl's own assembly the producer/consumer split is redundant (a single standard-phase generator would do); it exists to publish the contract across the generator boundary for external consumers (the 1->N platform). Standard-phase telemetry generation itself needs no experimental API. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../QylSemanticContractProducer.cs | 127 ++++++++++++++++++ .../QylSemanticTelemetryGenerator.cs | 108 +++++++++++++++ .../Fixture/OrderRequest.cs | 9 ++ ....SourceGenerators.Snapshots.Fixture.csproj | 5 + .../Fixture/app.qyl-semantic-contract.tsv | 3 + .../QylSemanticBindingAttribute.g.verified.cs | 18 +++ .../QylSemanticTelemetry.g.verified.cs | 13 ++ ...rderRequest.SemanticContract.g.verified.cs | 7 + tools/verify-generator-snapshots.py | 4 + 9 files changed, 294 insertions(+) create mode 100644 src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylSemanticContractProducer.cs create mode 100644 src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylSemanticTelemetryGenerator.cs create mode 100644 tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/OrderRequest.cs create mode 100644 tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/app.qyl-semantic-contract.tsv create mode 100644 tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/QylSemanticBindingAttribute.g.verified.cs create mode 100644 tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/QylSemanticTelemetry.g.verified.cs create mode 100644 tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/Qyl_Snapshot_Domain_OrderRequest.SemanticContract.g.verified.cs diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylSemanticContractProducer.cs b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylSemanticContractProducer.cs new file mode 100644 index 00000000..0fad135a --- /dev/null +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylSemanticContractProducer.cs @@ -0,0 +1,127 @@ +using ANcpLua.Roslyn.Utilities; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators; + +/// +/// Semantic-contract PRODUCER. Reads *.qyl-semantic-contract.tsv additional files and emits, +/// via RegisterPreCompilationSourceOutput (RSEXPERIMENTAL007), the shared +/// [QylSemanticBinding(property, attribute)] attribute plus attribute-decorated +/// partials of the user's types into the INITIAL compilation. +/// +/// Because the output lands in the initial compilation, it is visible to every other +/// generator's standard phase — qyl's own is the first +/// consumer, and third-party generators (logging, metrics, validation) can bind the same contract +/// without referencing qyl. Within qyl's own assembly the split is redundant; it exists to publish +/// the contract across the generator boundary. +/// +/// Inert by design: with no contract additional file (every existing demo), it emits nothing, +/// so it cannot affect projects that do not opt in. +/// +[Generator(LanguageNames.CSharp)] +public sealed class QylSemanticContractProducer : IIncrementalGenerator +{ + internal const string ContractFileSuffix = "qyl-semantic-contract.tsv"; + internal const string GeneratedNamespace = "Qyl.OpenTelemetry.AutoInstrumentation.Generated"; + internal const string BindingAttributeMetadataName = GeneratedNamespace + ".QylSemanticBindingAttribute"; + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var contract = context.AdditionalTextsProvider + .Where(static text => text.Path.Replace('\\', '/').EndsWithIgnoreCase(ContractFileSuffix)) + .Select(static (text, cancellationToken) => text.GetText(cancellationToken)?.ToString() ?? string.Empty) + .Collect(); + + context.RegisterPreCompilationSourceOutput(contract, static (precompilation, texts) => + { + var rows = ParseContract(string.Concat(texts)); + if (rows.Count is 0) + return; + + precompilation.AddSource("QylSemanticBindingAttribute.g.cs", SourceText.From(AttributeSource, Encoding.UTF8)); + + foreach (var byType in rows.GroupBy(static row => row.TypeFqn, StringComparer.Ordinal)) + { + precompilation.AddSource( + byType.Key.Replace('.', '_') + ".SemanticContract.g.cs", + SourceText.From(BuildContractPartial(byType.Key, byType.ToList()), Encoding.UTF8)); + } + }); + } + + private const string AttributeSource = +""" +// shared semantic-contract vocabulary (pre-compilation phase). +#nullable enable +namespace Qyl.OpenTelemetry.AutoInstrumentation.Generated; + +/// Binds a property of the annotated type to an OpenTelemetry attribute key. Emitted into +/// the initial compilation so any generator can read it from the semantic model. +[global::System.AttributeUsage(global::System.AttributeTargets.Class, AllowMultiple = true, Inherited = false)] +internal sealed class QylSemanticBindingAttribute : global::System.Attribute +{ + public QylSemanticBindingAttribute(string property, string attribute) + { + Property = property; + Attribute = attribute; + } + + public string Property { get; } + public string Attribute { get; } +} +"""; + + private readonly record struct ContractRow(string TypeFqn, string Property, string Attribute); + + private static List ParseContract(string tsv) + { + var rows = new List(); + foreach (var rawLine in tsv.Split('\n')) + { + var line = rawLine.TrimEnd('\r'); + if (line.Length is 0 || line[0] is '#') + continue; + + var columns = line.Split('\t'); + if (columns.Length < 3) + continue; + + rows.Add(new ContractRow(columns[0].Trim(), columns[1].Trim(), columns[2].Trim())); + } + + return rows; + } + + private static string BuildContractPartial(string typeFqn, List rows) + { + // Top-level types only: a dotted FQN cannot encode nested-type ('Outer+Inner') containment. + var lastDot = typeFqn.LastIndexOf('.'); + var namespaceName = lastDot < 0 ? null : typeFqn.Substring(0, lastDot); + var typeName = lastDot < 0 ? typeFqn : typeFqn.Substring(lastDot + 1); + + var builder = new StringBuilder(); + builder.AppendLine("// compiler-visible semantic contract (pre-compilation phase)."); + builder.AppendLine("#nullable enable"); + if (namespaceName is not null) + { + builder.AppendLine($"namespace {namespaceName};"); + builder.AppendLine(); + } + + foreach (var row in rows) + { + builder.AppendLine( + $"[global::{GeneratedNamespace}.QylSemanticBindingAttribute(\"{Escape(row.Property)}\", \"{Escape(row.Attribute)}\")]"); + } + + // No access modifier: a partial part without one merges with a public/internal user part + // without an accessibility conflict. The user's type must be declared `partial class`. + builder.AppendLine($"partial class {typeName} {{ }}"); + return builder.ToString(); + } + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); +} diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylSemanticTelemetryGenerator.cs b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylSemanticTelemetryGenerator.cs new file mode 100644 index 00000000..754fb215 --- /dev/null +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylSemanticTelemetryGenerator.cs @@ -0,0 +1,108 @@ +using ANcpLua.Roslyn.Utilities; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators; + +/// +/// Telemetry CONSUMER — the first consumer of the contract. +/// In its standard phase it binds the pre-compilation [QylSemanticBinding] attributes (visible +/// because they were emitted into the initial compilation) and emits an AOT-safe recorder per bound +/// type: pure activity.SetTag(...), no reflection, no IL rewrite. The generated telemetry is +/// compiled into the application. +/// +/// Inert by design: with no contract (no [QylSemanticBinding] types in the compilation) +/// it emits nothing. +/// +[Generator(LanguageNames.CSharp)] +public sealed class QylSemanticTelemetryGenerator : IIncrementalGenerator +{ + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterSourceOutput(context.CompilationProvider, static (context, compilation) => + { + var marker = compilation.GetTypeByMetadataName(QylSemanticContractProducer.BindingAttributeMetadataName); + if (marker is null) + return; + + var boundTypes = CollectBoundTypes(compilation, marker); + if (boundTypes.Count is 0) + return; + + context.AddSource("QylSemanticTelemetry.g.cs", SourceText.From(EmitRecorders(boundTypes), Encoding.UTF8)); + }); + } + + private readonly record struct BoundType(string TypeFqn, string SimpleName, IReadOnlyList<(string Property, string Attribute)> Bindings); + + private static List CollectBoundTypes(Compilation compilation, INamedTypeSymbol marker) + { + var result = new List(); + foreach (var type in EnumerateSourceTypes(compilation.GlobalNamespace)) + { + var bindings = new List<(string Property, string Attribute)>(); + foreach (var attribute in type.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, marker) && + attribute.GetConstructorArgument(0) is { } property && + attribute.GetConstructorArgument(1) is { } semanticAttribute) + { + bindings.Add((property, semanticAttribute)); + } + } + + if (bindings.Count > 0) + { + result.Add(new BoundType( + type.ToDisplayString(), + type.Name, + bindings.OrderBy(static binding => binding.Property, StringComparer.Ordinal).ToList())); + } + } + + return result.OrderBy(static bound => bound.TypeFqn, StringComparer.Ordinal).ToList(); + } + + private static IEnumerable EnumerateSourceTypes(INamespaceSymbol namespaceSymbol) + { + foreach (var type in namespaceSymbol.GetTypeMembers()) + { + if (type.Locations.Any(static location => location.IsInSource)) + yield return type; + } + + foreach (var child in namespaceSymbol.GetNamespaceMembers()) + { + foreach (var type in EnumerateSourceTypes(child)) + yield return type; + } + } + + private static string EmitRecorders(List boundTypes) + { + var builder = new StringBuilder(); + builder.AppendLine("// AOT-safe semantic telemetry generated from the qyl semantic contract."); + builder.AppendLine("#nullable enable"); + builder.AppendLine("namespace Qyl.OpenTelemetry.AutoInstrumentation.Generated;"); + + foreach (var bound in boundTypes) + { + builder.AppendLine($"/// Records OpenTelemetry semantic-convention tags for . Pure SetTag; no reflection."); + builder.AppendLine($"internal static class {bound.SimpleName}Telemetry"); + builder.AppendLine("{"); + builder.AppendLine($" public static void Record(global::System.Diagnostics.Activity? activity, global::{bound.TypeFqn} value)"); + builder.AppendLine(" {"); + builder.AppendLine(" if (activity is null) return;"); + foreach (var (property, attribute) in bound.Bindings) + builder.AppendLine($" activity.SetTag(\"{Escape(attribute)}\", value.{property});"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + } + + return builder.ToString(); + } + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); +} diff --git a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/OrderRequest.cs b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/OrderRequest.cs new file mode 100644 index 00000000..048bad98 --- /dev/null +++ b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/OrderRequest.cs @@ -0,0 +1,9 @@ +namespace Qyl.Snapshot.Domain; + +// Partial so the producer's pre-compilation [QylSemanticBinding] contract part merges into this type. +// The user writes no attributes; they come from app.qyl-semantic-contract.tsv via the producer. +public partial class OrderRequest +{ + public string CustomerId { get; init; } = ""; + public string OrderId { get; init; } = ""; +} diff --git a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots.Fixture.csproj b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots.Fixture.csproj index f697e8ed..ba2ae188 100644 --- a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots.Fixture.csproj +++ b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots.Fixture.csproj @@ -18,5 +18,10 @@ + + + + + diff --git a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/app.qyl-semantic-contract.tsv b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/app.qyl-semantic-contract.tsv new file mode 100644 index 00000000..2fb70b37 --- /dev/null +++ b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/app.qyl-semantic-contract.tsv @@ -0,0 +1,3 @@ +# Fully-qualified type Property semantic-convention attribute +Qyl.Snapshot.Domain.OrderRequest CustomerId customer.id +Qyl.Snapshot.Domain.OrderRequest OrderId order.id diff --git a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/QylSemanticBindingAttribute.g.verified.cs b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/QylSemanticBindingAttribute.g.verified.cs new file mode 100644 index 00000000..b9e79f22 --- /dev/null +++ b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/QylSemanticBindingAttribute.g.verified.cs @@ -0,0 +1,18 @@ +// shared semantic-contract vocabulary (pre-compilation phase). +#nullable enable +namespace Qyl.OpenTelemetry.AutoInstrumentation.Generated; + +/// Binds a property of the annotated type to an OpenTelemetry attribute key. Emitted into +/// the initial compilation so any generator can read it from the semantic model. +[global::System.AttributeUsage(global::System.AttributeTargets.Class, AllowMultiple = true, Inherited = false)] +internal sealed class QylSemanticBindingAttribute : global::System.Attribute +{ + public QylSemanticBindingAttribute(string property, string attribute) + { + Property = property; + Attribute = attribute; + } + + public string Property { get; } + public string Attribute { get; } +} \ No newline at end of file diff --git a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/QylSemanticTelemetry.g.verified.cs b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/QylSemanticTelemetry.g.verified.cs new file mode 100644 index 00000000..d7cdef4b --- /dev/null +++ b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/QylSemanticTelemetry.g.verified.cs @@ -0,0 +1,13 @@ +// AOT-safe semantic telemetry generated from the qyl semantic contract. +#nullable enable +namespace Qyl.OpenTelemetry.AutoInstrumentation.Generated; +/// Records OpenTelemetry semantic-convention tags for . Pure SetTag; no reflection. +internal static class OrderRequestTelemetry +{ + public static void Record(global::System.Diagnostics.Activity? activity, global::Qyl.Snapshot.Domain.OrderRequest value) + { + if (activity is null) return; + activity.SetTag("customer.id", value.CustomerId); + activity.SetTag("order.id", value.OrderId); + } +} diff --git a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/Qyl_Snapshot_Domain_OrderRequest.SemanticContract.g.verified.cs b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/Qyl_Snapshot_Domain_OrderRequest.SemanticContract.g.verified.cs new file mode 100644 index 00000000..168e1ebc --- /dev/null +++ b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/Qyl_Snapshot_Domain_OrderRequest.SemanticContract.g.verified.cs @@ -0,0 +1,7 @@ +// compiler-visible semantic contract (pre-compilation phase). +#nullable enable +namespace Qyl.Snapshot.Domain; + +[global::Qyl.OpenTelemetry.AutoInstrumentation.Generated.QylSemanticBindingAttribute("CustomerId", "customer.id")] +[global::Qyl.OpenTelemetry.AutoInstrumentation.Generated.QylSemanticBindingAttribute("OrderId", "order.id")] +partial class OrderRequest { } diff --git a/tools/verify-generator-snapshots.py b/tools/verify-generator-snapshots.py index eb75b3f0..fca7b6a3 100644 --- a/tools/verify-generator-snapshots.py +++ b/tools/verify-generator-snapshots.py @@ -22,6 +22,10 @@ EXPECTED_FILES = { "QylAutoInstrumentation.Interceptors.g.verified.cs": "QylAutoInstrumentation.Interceptors.g.cs", "QylGeneratedInstrumentationContract.g.verified.cs": "QylGeneratedInstrumentationContract.g.cs", + # Semantic-contract producer + telemetry consumer (opt-in via app.qyl-semantic-contract.tsv). + "QylSemanticBindingAttribute.g.verified.cs": "QylSemanticBindingAttribute.g.cs", + "Qyl_Snapshot_Domain_OrderRequest.SemanticContract.g.verified.cs": "Qyl_Snapshot_Domain_OrderRequest.SemanticContract.g.cs", + "QylSemanticTelemetry.g.verified.cs": "QylSemanticTelemetry.g.cs", } REQUIRED_INTERCEPTOR_TOKENS = [ From 6e12f18bbd38ab0c2290068081aa55979903a8dd Mon Sep 17 00:00:00 2001 From: ancplua Date: Fri, 26 Jun 2026 10:51:15 +0200 Subject: [PATCH 08/10] feat(tcg): Telemetry Capability Graph generator + exchange schema + North Star MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-Light step 1 of the self-describing observability substrate. - TelemetryCapabilityGraphGenerator bakes the 60-capability TCG into the core assembly as QylTelemetryCapabilityGraph.Json, gated to core (mirrors SemConvRegistryGenerator). Each capability is tagged with provenance (compile-time / runtime / control / unsupported) — the distinction a sampling consumer cannot recover. - docs/schema/telemetry-capability-graph.schema.json (JSON Schema 2020-12) + docs/TELEMETRY_CAPABILITY_GRAPH.md exchange spec (provenance vocabulary, semver/forward-compat, OTLP resource-log publication mapping, consumer patterns); validated against the emitted artifact. - AGENTS.md reframed to the self-describing-observability North Star with operational invariants preserved. Full solution build 0 warnings / 0 errors; generator-snapshots-ok. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 70 +++++- docs/TELEMETRY_CAPABILITY_GRAPH.md | 122 +++++++++ .../telemetry-capability-graph.schema.json | 117 +++++++++ .../TelemetryCapabilityGraphGenerator.cs | 235 ++++++++++++++++++ 4 files changed, 536 insertions(+), 8 deletions(-) create mode 100644 docs/TELEMETRY_CAPABILITY_GRAPH.md create mode 100644 docs/schema/telemetry-capability-graph.schema.json create mode 100644 src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/TelemetryCapabilityGraphGenerator.cs diff --git a/AGENTS.md b/AGENTS.md index 4aca3aba..365be2e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,14 +2,58 @@ ## Mission -This repository is the runtime AOT auto-instrumentation lane for qyl. Keep it separate from: +This repository is the runtime AOT auto-instrumentation lane for qyl, evolving into a +**self-describing observability substrate**. The foundation is unchanged: .NET 10 +NativeAOT-compatible zero-code instrumentation through managed build assets, source generation, +DiagnosticListener consumption, and module-initializer boot. The direction is the North Star +below. -- semantic-convention package generation, -- the old CLR-profiler/OpenTelemetry auto-instrumentation substrate, -- unrelated compile-time tracing experiments. +Keep this repository separate from: -The product goal is .NET 10 NativeAOT-compatible zero-code instrumentation through managed -build assets, source generation, DiagnosticListener consumption, and module-initializer boot. +- semantic-convention package generation (`Qyl.OpenTelemetry.SemanticConventions` is a *referenced* + vocabulary package, not generated here), +- the old CLR-profiler/OpenTelemetry auto-instrumentation substrate, +- unrelated compile-time tracing experiments (the `experiment/` and `spike/` trees stay outside + the `.slnx` / production build graph — see `docs/experiments/`). + +## North Star — declare and prove the whole stack + +Every observability tool today is **pull-by-observation**: a backend learns what a service emits +by receiving samples over time, and never knows whether it has seen the whole surface. qyl has a +capability none of them have — because instrumentation is source-generated interceptors + a static +contract + a referenced semconv registry + (incrementally) DTO inference, **the complete set of +telemetry a binary can ever produce is a compile-time-derivable fact, with provenance.** + +The substrate goal: every qyl binary ships a complete, machine-readable **Telemetry Capability +Graph (TCG)** — the full possible OpenTelemetry surface for that exact binary, each capability +tagged compile-time-owned vs runtime-valued — and *proves* it by self-hosting (instrumenting its +own pipeline with its own mechanism, zero extra code). Any external entity consumes the TCG to know +the entire stack before a span is sampled. The contract becomes the shared semantic graph; an OTLP +backend is just one consumer. + +Three pillars: + +1. **Self-host (the proof).** qyl instruments qyl with qyl — `QylSelfTelemetry` / + `SemConvConformanceProcessor` are the seed; the binary observing itself is how "declared TCG == + runtime reality" is checked. +2. **Compile-time-complete TCG (the artifact).** A generated, deterministic manifest of every span + name / metric / attribute key the binary can produce, with provenance. Only compile-time + instrumentation can enumerate this honestly. +3. **Open exchange (the reach).** The TCG published vendor-neutrally (OTel Resource/Scope + a static + artifact + a queryable surface) so a backend can pre-provision, a collector can validate against + the declared surface, and CI can treat telemetry as a typed, versioned API. + +**Status (current tree — do not overstate):** First-Light step 1 is shipped — +`TelemetryCapabilityGraphGenerator` bakes the TCG into the core assembly as +`Qyl.OpenTelemetry.AutoInstrumentation.Internal.QylTelemetryCapabilityGraph.Json` (gated to the core +assembly, like `SemConvRegistryGenerator`). Next: runtime publication (emit the TCG as an OTel +resource/log at boot) and the vendor-neutral exchange schema. + +**What does NOT change:** runtime DiagnosticListeners stay. `docs/experiments/precompilation-verdict.md` +measured that ~95% of attribute *values* are runtime-only — listeners are the **runtime lane** of +the TCG, not a failure to delete. Semantic ownership trends toward compile time; runtime owns +observations. "Missing values stay missing; never synthesize" still governs every lane, including +any future inference. ## Clean slate before work @@ -69,6 +113,10 @@ files left behind. flags switch redacted to raw); `db.query.text` sits behind the upstream `SET_DBSTATEMENT_FOR_TEXT` flags; the conformance processor behind `QYL_CONFORMANCE_ENABLED=1`. +6. The **Telemetry Capability Graph** (`TelemetryCapabilityGraphGenerator`) bakes the instrumentation + contract into the core assembly as `QylTelemetryCapabilityGraph.Json` — the binary's declared, + provenance-tagged telemetry surface (North Star pillar 2). Generated from the same contract data, + so it is a pure function of its inputs and never drifts from what the lanes above actually emit. Key gotcha: a bare `ProjectReference` to the runtime project does NOT flow analyzer/build assets — `PackageReference` is the supported zero-code path. The dogfood path references the @@ -79,8 +127,9 @@ runtime project, the generator project as analyzer, and the core targets file ex The accepted qyl instrumentation mechanisms here are exactly these: ordinary C# compiled into the app, source-generated interceptors, build-transitive assets, module-initializer activation, -BCL telemetry primitives, and public library diagnostic payloads. Build every integration out -of these. +BCL telemetry primitives, and public library diagnostic payloads. Build every integration — and +the entire self-describing surface (the TCG, self-host telemetry, any future inference) — out of +these and nothing else. The following are the old substrate this repo deliberately replaced; reintroducing any of them into product code or package assets is out of bounds: @@ -121,6 +170,8 @@ Generated/evidence surfaces include: - source-generator verified snapshots, - OTLP/verified fixture files, - generated coverage matrix, +- the Telemetry Capability Graph (`QylTelemetryCapabilityGraph.g.cs`, from the instrumentation + contract via `TelemetryCapabilityGraphGenerator`), - package build/buildTransitive generated assets. If a generated file is obsolete, delete it only with the generator/input change that makes it @@ -129,6 +180,8 @@ obsolete. ## Documentation rules - Document current-tree behavior, not old PR state or ceremonial progress claims. +- The North Star is direction, not a claim of completion — mark what is shipped vs next, and never + describe a future pillar as if it already exists. - Do not claim a tag or GitHub Release is current without checking its target commit. - Keep README user-facing and operational. - Keep CHANGELOG synthetic and useful for continuation, not a raw commit dump. @@ -144,6 +197,7 @@ Use the narrowest verifier that covers the changed surface: | ProjectReference behavior | `python3 tools/verify-projectreference-behavior.py` | | Source generator snapshots | `python3 tools/verify-generator-snapshots.py` | | Source interceptor behavior | `python3 tools/verify-source-interceptor-consumer.py` | +| Telemetry Capability Graph | `dotnet build src/Qyl.OpenTelemetry.AutoInstrumentation/Qyl.OpenTelemetry.AutoInstrumentation.csproj` (emits `QylTelemetryCapabilityGraph.g.cs`) | | NativeAOT smoke | `bash tools/smoketest.sh` | | OTLP fixtures | `python3 tools/verify-otlp-fixtures.py` and `python3 tools/verify-otlp-collector-fixtures.py` | | Whole repo handoff | `python3 tools/verify-aot-autoinstrumentation-goal.py` | diff --git a/docs/TELEMETRY_CAPABILITY_GRAPH.md b/docs/TELEMETRY_CAPABILITY_GRAPH.md new file mode 100644 index 00000000..530e9751 --- /dev/null +++ b/docs/TELEMETRY_CAPABILITY_GRAPH.md @@ -0,0 +1,122 @@ +# Telemetry Capability Graph (TCG) — exchange spec + +> Status: **v0.1.0-draft.** The generator (pillar 2) ships; the runtime publication channel and the +> queryable endpoint below are the next First-Light steps, marked **(planned)**. Document +> current-tree truth — do not describe a planned channel as if it exists. + +## Why this exists + +Every observability tool today is **pull-by-observation**: a backend learns what a service emits by +receiving samples over time, and never knows whether it has seen the whole surface. A qyl binary can +do the opposite — **declare** its complete possible OpenTelemetry surface, deterministically, before +a single span is sampled, because that surface is a compile-time fact (source-generated interceptors ++ a static contract + a referenced semconv vocabulary). The TCG is that declaration in a +vendor-neutral form any external entity can consume. It is generated, never hand-written, so it +cannot drift from what the binary actually emits. + +The schema is [`schema/telemetry-capability-graph.schema.json`](schema/telemetry-capability-graph.schema.json) +(JSON Schema 2020-12). The producer is `TelemetryCapabilityGraphGenerator`, which bakes the document +into the core assembly as the constant +`Qyl.OpenTelemetry.AutoInstrumentation.Internal.QylTelemetryCapabilityGraph.Json`. + +## Document shape + +```jsonc +{ + "schemaVersion": "0.1.0-draft", + "generator": "Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.TelemetryCapabilityGraphGenerator", + "capabilityCount": 60, + "signals": { "traces": 44, "metrics": 10, "logs": 5 }, + "capabilities": [ + { + "id": "OTEL_DOTNET_AUTO_CONTRACT_003", // stable cross-version join key + "key": "signals.traces.ASPNETCORE", + "signal": "traces", // traces | metrics | logs | none + "lane": "RuntimePublicTelemetry", + "status": "Implemented", + "payloadAccess": "TypedPublic", // TypedPublic | ReflectionRequired | NotApplicable + "provenance": "runtime", // compile-time | runtime | control | unsupported | unknown + "instrumentationId": "ASPNETCORE", + "supportedVersions": "*", + "owner": "Qyl public telemetry listeners/meters", + "libraries": ["ASP.NET Core"], + "attributes": [] // declared semconv keys; populated as lanes fill the contract + } + ] +} +``` + +## The `provenance` vocabulary (the load-bearing field) + +This is the field a sampling consumer can never recover. It answers "where does each capability's +meaning come from?": + +| provenance | meaning | derived from lane | +|---|---|---| +| `compile-time` | attribute keys are owned at compile time by an interceptor or framework-init | `SourceInterceptor`, `FrameworkInitialization` | +| `runtime` | attribute *values* are read at runtime from typed payloads by a listener | `RuntimePublicTelemetry`, `OfficialLibraryHook` | +| `control` | a control surface (env var / option), not a span/metric/log itself | `EnvironmentControl`, `InstrumentationOption` | +| `unsupported` | not available on the NativeAOT substrate | `UnsupportedNativeAot` | +| `unknown` | catch-all; consumers MUST map unrecognized values here | — | + +Provenance is the bridge between the North Star halves: *semantic ownership trends to compile time, +runtime owns observations.* `runtime` capabilities are exactly where DiagnosticListeners are +load-bearing and cannot be deleted (`docs/experiments/precompilation-verdict.md`: ~95% of attribute +values are runtime-only). + +## Versioning & compatibility + +- `schemaVersion` is semver. Within a **major**, changes are additive: new object properties, new + enum members, new capabilities. A `-draft` pre-release may still change shape. +- **Producers** emit all `required` fields and may add properties. +- **Consumers** MUST: ignore unknown object properties; treat unknown `signal` / `lane` / `status` / + `payloadAccess` / `provenance` members as the documented catch-all (`unknown` for provenance, + otherwise pass-through); join across versions on `capability.id`, never on array index. +- A breaking change (removing/renaming a field, changing a field's meaning) bumps the **major** and + is announced in the repo CHANGELOG. + +## Publication channels (how an external entity gets the TCG) + +1. **In-binary constant (shipped).** `QylTelemetryCapabilityGraph.Json`. The authoritative source; + every other channel is derived from it. +2. **OTel resource log at boot (planned).** Emit the document once at process start as a single OTLP + `LogRecord` so any collector/backend ingests it through the normal logs pipeline — no qyl-specific + protocol. Proposed mapping: + - `LogRecord.EventName` = `qyl.telemetry_capability_graph` + - `LogRecord.Body` = the TCG JSON (string) + - `LogRecord.SeverityNumber` = INFO + - `LogRecord.Attributes`: `qyl.tcg.schema_version`, `qyl.tcg.capability_count` + - **Resource** attributes (so the surface is discoverable on *every* export, cheaply, without the + full body): `qyl.tcg.schema_version`, `qyl.tcg.capability_count`. The full body stays on the + one log record, not on the resource, to avoid per-batch bloat. +3. **Static build artifact (planned).** `app.telemetry-manifest.json` written at build time (MSBuild + step extracting the constant) for CI / compliance / offline consumers. +4. **Queryable surface (planned).** The constant is in the binary; a diagnostic endpoint or CLI can + return it so a running service answers "what is your complete telemetry surface?" authoritatively. + +## Consumer patterns (what the TCG unlocks) + +- **Backend pre-provisioning.** Read the TCG on first contact; create dashboards/alerts for the full + declared surface before span #1 arrives. Group by `signal`; size cardinality budgets from + `provenance` (runtime keys carry values, compile-time keys are fixed). +- **Collector validation.** A collector holding the TCG drops/flags telemetry whose attribute keys + are not in the declared surface — edge-enforced anti-drift and anti-cardinality. (Pairs with the + existing `SemConvConformanceProcessor`, which does the in-process version under + `QYL_CONFORMANCE_ENABLED=1`.) +- **CI gate / typed telemetry API.** Diff a PR's TCG against the merge base: a new capability is a new + observable surface; a removed one is a breaking change for downstream consumers. Telemetry becomes + a versioned contract, joined on `capability.id`. +- **Cross-service typing.** Service A's TCG declares what it emits; a mesh or service B knows the + upstream telemetry shape statically. + +## Relationship to the generator + +The TCG is generated output (see AGENTS.md → *Generated and evidence files*): its single source of +truth is `TelemetryCapabilityGraphGenerator` + `InstrumentationContract`. To change the TCG, change +the contract or the generator and rebuild — never hand-edit the emitted constant. Validate with: + +```bash +dotnet build src/Qyl.OpenTelemetry.AutoInstrumentation/Qyl.OpenTelemetry.AutoInstrumentation.csproj \ + -p:EmitCompilerGeneratedFiles=true +# then validate the emitted constant against schema/telemetry-capability-graph.schema.json +``` diff --git a/docs/schema/telemetry-capability-graph.schema.json b/docs/schema/telemetry-capability-graph.schema.json new file mode 100644 index 00000000..03ed104a --- /dev/null +++ b/docs/schema/telemetry-capability-graph.schema.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ANcpLua/Qyl.OpenTelemetry.AutoInstrumentation/docs/schema/telemetry-capability-graph.schema.json", + "title": "Qyl Telemetry Capability Graph (TCG)", + "description": "A complete, machine-readable, provenance-tagged manifest of every telemetry capability a qyl-instrumented binary can produce. Generated deterministically from the instrumentation contract — never sampled. Consumers MUST tolerate unknown object properties and unknown enum members (treat the latter as the documented catch-all) for forward compatibility.", + "type": "object", + "required": ["schemaVersion", "generator", "capabilityCount", "signals", "capabilities"], + "additionalProperties": true, + "properties": { + "schemaVersion": { + "type": "string", + "description": "Semantic version of THIS document format. Within a major version, changes are additive; consumers tolerate unknown fields. The '-draft' pre-release marks a format that may still change.", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z.-]+)?$" + }, + "generator": { + "type": "string", + "description": "Fully-qualified type that produced this document (provenance of the manifest itself)." + }, + "capabilityCount": { + "type": "integer", + "minimum": 0, + "description": "Number of entries in 'capabilities'. MUST equal capabilities.length." + }, + "signals": { + "type": "object", + "description": "Per-signal capability counts (a fast summary so a consumer can size dashboards before reading the full list).", + "required": ["traces", "metrics", "logs"], + "additionalProperties": true, + "properties": { + "traces": { "type": "integer", "minimum": 0 }, + "metrics": { "type": "integer", "minimum": 0 }, + "logs": { "type": "integer", "minimum": 0 } + } + }, + "capabilities": { + "type": "array", + "description": "Every telemetry capability the binary can produce, in deterministic contract order.", + "items": { "$ref": "#/$defs/capability" } + } + }, + "$defs": { + "capability": { + "type": "object", + "required": [ + "id", "key", "signal", "lane", "status", + "payloadAccess", "provenance", "instrumentationId", + "supportedVersions", "owner", "libraries", "attributes" + ], + "additionalProperties": true, + "properties": { + "id": { + "type": "string", + "description": "Stable contract identifier (e.g. OTEL_DOTNET_AUTO_CONTRACT_001). Use this as the join key across TCG versions." + }, + "key": { + "type": "string", + "description": "Capability key, e.g. signals.traces.ASPNETCORE." + }, + "signal": { + "type": "string", + "description": "OpenTelemetry signal this capability contributes to.", + "enum": ["traces", "metrics", "logs", "none"] + }, + "lane": { + "type": "string", + "description": "The qyl mechanism lane that owns this capability.", + "enum": [ + "SourceInterceptor", + "RuntimePublicTelemetry", + "FrameworkInitialization", + "OfficialLibraryHook", + "EnvironmentControl", + "InstrumentationOption", + "UnsupportedNativeAot" + ] + }, + "status": { + "type": "string", + "description": "Implementation status of the capability in the current build.", + "enum": ["Implemented", "ControlBound", "OptionBound", "ResearchRequired", "UnsupportedNativeAot"] + }, + "payloadAccess": { + "type": "string", + "description": "How the lane accesses the underlying payload: typed-public (AOT-safe), reflection-required (not AOT-safe), or not-applicable (compile-time only).", + "enum": ["TypedPublic", "ReflectionRequired", "NotApplicable"] + }, + "provenance": { + "type": "string", + "description": "The load-bearing distinction: whether the capability's attribute keys are owned at compile time, carried as runtime payload values, are a control surface (env/option), or unsupported on the AOT substrate. Consumers MUST map unknown members to 'unknown'.", + "enum": ["compile-time", "runtime", "control", "unsupported", "unknown"] + }, + "instrumentationId": { + "type": "string", + "description": "Short integration id (e.g. ASPNETCORE, SQLCLIENT)." + }, + "supportedVersions": { + "type": "string", + "description": "Human-readable supported version range(s) of the instrumented library, or '*'." + }, + "owner": { + "type": "string", + "description": "The component that produces this capability (a generator, the public telemetry listeners, etc.)." + }, + "libraries": { + "type": "array", + "description": "Instrumented library/framework name(s).", + "items": { "type": "string" } + }, + "attributes": { + "type": "array", + "description": "Declared semantic-convention attribute keys this capability sets. Empty until a lane populates the contract's AttributeNames (e.g. the future DTO-inference lane).", + "items": { "type": "string" } + } + } + } + } +} diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/TelemetryCapabilityGraphGenerator.cs b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/TelemetryCapabilityGraphGenerator.cs new file mode 100644 index 00000000..42946acf --- /dev/null +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/TelemetryCapabilityGraphGenerator.cs @@ -0,0 +1,235 @@ +using System.Collections.Immutable; +using System.Globalization; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators; + +/// +/// Emits the qyl Telemetry Capability Graph (TCG): a machine-readable manifest of every telemetry +/// capability qyl can produce, baked as a compile-time const into the core assembly. +/// +/// +/// First-Light step 1 of the self-describing-observability substrate. Instead of leaving an external +/// entity to discover a service's telemetry surface by sampling exported spans over time, the binary +/// declares its complete possible OpenTelemetry surface — derived deterministically from the +/// instrumentation contract. Each capability is tagged with provenance (whether the attribute +/// keys are owned at compile time by an interceptor/init lane, or carried at runtime by a listener), +/// which is exactly the distinction a consumer cannot recover from samples alone. +/// +/// +/// Like , the generator runs only inside the compiler and emits +/// only into the Qyl.OpenTelemetry.AutoInstrumentation assembly. The manifest is a pure function +/// of , so the output is byte-reproducible. +/// +/// +[Generator(LanguageNames.CSharp)] +public sealed class TelemetryCapabilityGraphGenerator : IIncrementalGenerator +{ + private const string CoreAssemblyName = "Qyl.OpenTelemetry.AutoInstrumentation"; + private const string SchemaVersion = "0.1.0-draft"; + private const string GeneratorId = + "Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.TelemetryCapabilityGraphGenerator"; + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var emitToThisAssembly = context.CompilationProvider.Select( + static (compilation, _) => + string.Equals(compilation.AssemblyName, CoreAssemblyName, StringComparison.Ordinal)); + + context.RegisterSourceOutput(emitToThisAssembly, static (sourceContext, emit) => + { + if (!emit) + return; + + sourceContext.AddSource( + "QylTelemetryCapabilityGraph.g.cs", + SourceText.From(EmitSource(), Encoding.UTF8)); + }); + } + + private static string EmitSource() + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine("namespace Qyl.OpenTelemetry.AutoInstrumentation.Internal;"); + builder.AppendLine(); + builder.AppendLine("/// "); + builder.AppendLine("/// The qyl Telemetry Capability Graph: the complete, machine-readable manifest of every"); + builder.AppendLine("/// telemetry capability this build can produce, generated from the instrumentation contract."); + builder.AppendLine("/// "); + builder.AppendLine("internal static class QylTelemetryCapabilityGraph"); + builder.AppendLine("{"); + builder.AppendLine(" /// The Telemetry Capability Graph serialized as compact JSON (schema " + SchemaVersion + ")."); + builder.Append(" public const string Json = "); + AppendCSharpStringLiteral(builder, BuildManifestJson()); + builder.AppendLine(";"); + builder.AppendLine("}"); + return builder.ToString(); + } + + private static string BuildManifestJson() + { + var items = InstrumentationContract.Items; + + var traces = 0; + var metrics = 0; + var logs = 0; + foreach (var item in items) + { + switch (item.Signal) + { + case InstrumentationSignal.Traces: + traces++; + break; + case InstrumentationSignal.Metrics: + metrics++; + break; + case InstrumentationSignal.Logs: + logs++; + break; + } + } + + var json = new StringBuilder(); + json.Append('{'); + AppendProperty(json, "schemaVersion", SchemaVersion); + json.Append(','); + AppendProperty(json, "generator", GeneratorId); + json.Append(','); + json.Append("\"capabilityCount\":").Append(items.Length.ToString(CultureInfo.InvariantCulture)); + json.Append(','); + json.Append("\"signals\":{"); + json.Append("\"traces\":").Append(traces.ToString(CultureInfo.InvariantCulture)).Append(','); + json.Append("\"metrics\":").Append(metrics.ToString(CultureInfo.InvariantCulture)).Append(','); + json.Append("\"logs\":").Append(logs.ToString(CultureInfo.InvariantCulture)); + json.Append("},"); + json.Append("\"capabilities\":["); + for (var i = 0; i < items.Length; i++) + { + if (i > 0) + json.Append(','); + AppendCapability(json, items[i]); + } + + json.Append(']'); + json.Append('}'); + return json.ToString(); + } + + private static void AppendCapability(StringBuilder json, in InstrumentationContractItem item) + { + json.Append('{'); + AppendProperty(json, "id", item.ContractItemId); + json.Append(','); + AppendProperty(json, "key", item.Key); + json.Append(','); + AppendProperty(json, "signal", SignalName(item.Signal)); + json.Append(','); + AppendProperty(json, "lane", item.Lane.ToString()); + json.Append(','); + AppendProperty(json, "status", item.QylStatus.ToString()); + json.Append(','); + AppendProperty(json, "payloadAccess", item.PayloadAccess.ToString()); + json.Append(','); + AppendProperty(json, "provenance", Provenance(item.Lane)); + json.Append(','); + AppendProperty(json, "instrumentationId", item.InstrumentationId); + json.Append(','); + AppendProperty(json, "supportedVersions", item.SupportedVersions); + json.Append(','); + AppendProperty(json, "owner", item.PrimaryOwner); + json.Append(','); + AppendStringArray(json, "libraries", item.Libraries); + json.Append(','); + AppendStringArray(json, "attributes", item.AttributeNames); + json.Append('}'); + } + + private static string SignalName(InstrumentationSignal signal) => signal switch + { + InstrumentationSignal.Traces => "traces", + InstrumentationSignal.Metrics => "metrics", + InstrumentationSignal.Logs => "logs", + _ => "none", + }; + + // The vision's load-bearing distinction: which attribute keys are owned at compile time + // (interceptor / framework-init lanes) versus carried as runtime payload values (listener lanes). + private static string Provenance(InstrumentationContractLane lane) => lane switch + { + InstrumentationContractLane.SourceInterceptor => "compile-time", + InstrumentationContractLane.FrameworkInitialization => "compile-time", + InstrumentationContractLane.RuntimePublicTelemetry => "runtime", + InstrumentationContractLane.OfficialLibraryHook => "runtime", + InstrumentationContractLane.EnvironmentControl => "control", + InstrumentationContractLane.InstrumentationOption => "control", + InstrumentationContractLane.UnsupportedNativeAot => "unsupported", + _ => "unknown", + }; + + private static void AppendProperty(StringBuilder json, string name, string value) + { + AppendJsonString(json, name); + json.Append(':'); + AppendJsonString(json, value); + } + + private static void AppendStringArray(StringBuilder json, string name, ImmutableArray values) + { + AppendJsonString(json, name); + json.Append(":["); + for (var i = 0; i < values.Length; i++) + { + if (i > 0) + json.Append(','); + AppendJsonString(json, values[i]); + } + + json.Append(']'); + } + + private static void AppendJsonString(StringBuilder json, string value) + { + json.Append('"'); + foreach (var c in value) + { + switch (c) + { + case '"': + json.Append("\\\""); + break; + case '\\': + json.Append("\\\\"); + break; + case '\n': + json.Append("\\n"); + break; + case '\r': + json.Append("\\r"); + break; + case '\t': + json.Append("\\t"); + break; + default: + if (c < ' ') + json.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); + else + json.Append(c); + break; + } + } + + json.Append('"'); + } + + private static void AppendCSharpStringLiteral(StringBuilder builder, string value) + { + builder.Append('"'); + builder.Append(value.Replace("\\", "\\\\").Replace("\"", "\\\"")); + builder.Append('"'); + } +} From 1fbe83c48f0099da110217c5885b7dd41aae13e7 Mon Sep 17 00:00:00 2001 From: ancplua Date: Fri, 26 Jun 2026 10:57:50 +0200 Subject: [PATCH 09/10] feat(tcg): public accessor for the Telemetry Capability Graph (First-Light step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes step 1's generated manifest consumable without an OTel SDK dependency and without emitting new runtime telemetry (no OTLP-fixture risk) — the 'queryable surface' channel. - QylTelemetryCapabilityGraph public static accessor: Json / SchemaVersion / CapabilityCount. - Generator now emits SchemaVersion + CapabilityCount consts; generated holder renamed to Internal.QylTelemetryCapabilityGraphData (the public type takes the QylTelemetryCapabilityGraph name). - PublicAPI.Unshipped baseline updated; AGENTS.md + exchange spec status synced to steps 1-2 shipped. public-api-baseline-ok; generator-snapshots-ok; full solution build 0 warnings / 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 13 ++++++---- docs/TELEMETRY_CAPABILITY_GRAPH.md | 16 ++++++------ .../TelemetryCapabilityGraphGenerator.cs | 19 +++++++++++--- .../PublicAPI.Unshipped.txt | 4 +++ .../QylTelemetryCapabilityGraph.cs | 25 +++++++++++++++++++ 5 files changed, 61 insertions(+), 16 deletions(-) create mode 100644 src/Qyl.OpenTelemetry.AutoInstrumentation/QylTelemetryCapabilityGraph.cs diff --git a/AGENTS.md b/AGENTS.md index 365be2e7..9d2a1101 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,11 +43,14 @@ Three pillars: artifact + a queryable surface) so a backend can pre-provision, a collector can validate against the declared surface, and CI can treat telemetry as a typed, versioned API. -**Status (current tree — do not overstate):** First-Light step 1 is shipped — -`TelemetryCapabilityGraphGenerator` bakes the TCG into the core assembly as -`Qyl.OpenTelemetry.AutoInstrumentation.Internal.QylTelemetryCapabilityGraph.Json` (gated to the core -assembly, like `SemConvRegistryGenerator`). Next: runtime publication (emit the TCG as an OTel -resource/log at boot) and the vendor-neutral exchange schema. +**Status (current tree — do not overstate):** First-Light steps 1–2 are shipped — +`TelemetryCapabilityGraphGenerator` bakes the TCG into the core assembly (generated +`Internal.QylTelemetryCapabilityGraphData`, gated to the core assembly like `SemConvRegistryGenerator`), +exposed publicly as `QylTelemetryCapabilityGraph.Json` / `.SchemaVersion` / `.CapabilityCount` (the +queryable surface), with the vendor-neutral exchange schema in +`docs/schema/telemetry-capability-graph.schema.json` and `docs/TELEMETRY_CAPABILITY_GRAPH.md`. Next: +the OTLP resource-log publication channel at boot (mapping already specified in the exchange spec) — +note qyl carries no OTel SDK dependency, so that channel needs a BCL-native emission decision. **What does NOT change:** runtime DiagnosticListeners stay. `docs/experiments/precompilation-verdict.md` measured that ~95% of attribute *values* are runtime-only — listeners are the **runtime lane** of diff --git a/docs/TELEMETRY_CAPABILITY_GRAPH.md b/docs/TELEMETRY_CAPABILITY_GRAPH.md index 530e9751..e5682840 100644 --- a/docs/TELEMETRY_CAPABILITY_GRAPH.md +++ b/docs/TELEMETRY_CAPABILITY_GRAPH.md @@ -15,9 +15,9 @@ vendor-neutral form any external entity can consume. It is generated, never hand cannot drift from what the binary actually emits. The schema is [`schema/telemetry-capability-graph.schema.json`](schema/telemetry-capability-graph.schema.json) -(JSON Schema 2020-12). The producer is `TelemetryCapabilityGraphGenerator`, which bakes the document -into the core assembly as the constant -`Qyl.OpenTelemetry.AutoInstrumentation.Internal.QylTelemetryCapabilityGraph.Json`. +(JSON Schema 2020-12). The producer is `TelemetryCapabilityGraphGenerator`, which bakes the document into the core assembly +as the generated constant `Internal.QylTelemetryCapabilityGraphData.Json`, exposed publicly as +`QylTelemetryCapabilityGraph.Json` (with `.SchemaVersion` and `.CapabilityCount`). ## Document shape @@ -77,8 +77,9 @@ values are runtime-only). ## Publication channels (how an external entity gets the TCG) -1. **In-binary constant (shipped).** `QylTelemetryCapabilityGraph.Json`. The authoritative source; - every other channel is derived from it. +1. **In-binary constant + public accessor (shipped).** `QylTelemetryCapabilityGraph.Json` / + `.SchemaVersion` / `.CapabilityCount`. The authoritative source; every other channel is derived + from it. 2. **OTel resource log at boot (planned).** Emit the document once at process start as a single OTLP `LogRecord` so any collector/backend ingests it through the normal logs pipeline — no qyl-specific protocol. Proposed mapping: @@ -91,8 +92,9 @@ values are runtime-only). one log record, not on the resource, to avoid per-batch bloat. 3. **Static build artifact (planned).** `app.telemetry-manifest.json` written at build time (MSBuild step extracting the constant) for CI / compliance / offline consumers. -4. **Queryable surface (planned).** The constant is in the binary; a diagnostic endpoint or CLI can - return it so a running service answers "what is your complete telemetry surface?" authoritatively. +4. **Queryable surface (partial).** The public accessor above already returns the document in-process; + exposing it over the wire (a diagnostic endpoint or CLI) so a running service answers "what is your + complete telemetry surface?" remotely is still planned. ## Consumer patterns (what the TCG unlocks) diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/TelemetryCapabilityGraphGenerator.cs b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/TelemetryCapabilityGraphGenerator.cs index 42946acf..6a5836dd 100644 --- a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/TelemetryCapabilityGraphGenerator.cs +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/TelemetryCapabilityGraphGenerator.cs @@ -58,12 +58,23 @@ private static string EmitSource() builder.AppendLine("namespace Qyl.OpenTelemetry.AutoInstrumentation.Internal;"); builder.AppendLine(); builder.AppendLine("/// "); - builder.AppendLine("/// The qyl Telemetry Capability Graph: the complete, machine-readable manifest of every"); - builder.AppendLine("/// telemetry capability this build can produce, generated from the instrumentation contract."); + builder.AppendLine("/// Generated backing data for the qyl Telemetry Capability Graph (TCG): the complete,"); + builder.AppendLine("/// machine-readable manifest of every telemetry capability this build can produce."); + builder.AppendLine("/// Public access is through Qyl.OpenTelemetry.AutoInstrumentation.QylTelemetryCapabilityGraph."); builder.AppendLine("/// "); - builder.AppendLine("internal static class QylTelemetryCapabilityGraph"); + builder.AppendLine("internal static class QylTelemetryCapabilityGraphData"); builder.AppendLine("{"); - builder.AppendLine(" /// The Telemetry Capability Graph serialized as compact JSON (schema " + SchemaVersion + ")."); + builder.AppendLine(" /// Schema version of the emitted manifest."); + builder.Append(" public const string SchemaVersion = "); + AppendCSharpStringLiteral(builder, SchemaVersion); + builder.AppendLine(";"); + builder.AppendLine(); + builder.AppendLine(" /// Number of capabilities declared in the manifest."); + builder.AppendLine( + " public const int CapabilityCount = " + + InstrumentationContract.Items.Length.ToString(CultureInfo.InvariantCulture) + ";"); + builder.AppendLine(); + builder.AppendLine(" /// The Telemetry Capability Graph serialized as compact JSON."); builder.Append(" public const string Json = "); AppendCSharpStringLiteral(builder, BuildManifestJson()); builder.AppendLine(";"); diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation/PublicAPI.Unshipped.txt b/src/Qyl.OpenTelemetry.AutoInstrumentation/PublicAPI.Unshipped.txt index 06332eab..bedc179c 100644 --- a/src/Qyl.OpenTelemetry.AutoInstrumentation/PublicAPI.Unshipped.txt +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation/PublicAPI.Unshipped.txt @@ -22,3 +22,7 @@ static Qyl.OpenTelemetry.AutoInstrumentation.QylInterceptedDbCommand.GetTimestam static Qyl.OpenTelemetry.AutoInstrumentation.QylInterceptedDbCommand.RecordDuration(long startTimestamp, string! instrumentationId) -> void static Qyl.OpenTelemetry.AutoInstrumentation.QylInterceptedNServiceBus.GetTimestamp() -> long static Qyl.OpenTelemetry.AutoInstrumentation.QylInterceptedNServiceBus.RecordDuration(long startTimestamp, string! operationName) -> void +Qyl.OpenTelemetry.AutoInstrumentation.QylTelemetryCapabilityGraph +static Qyl.OpenTelemetry.AutoInstrumentation.QylTelemetryCapabilityGraph.CapabilityCount.get -> int +static Qyl.OpenTelemetry.AutoInstrumentation.QylTelemetryCapabilityGraph.Json.get -> string! +static Qyl.OpenTelemetry.AutoInstrumentation.QylTelemetryCapabilityGraph.SchemaVersion.get -> string! diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation/QylTelemetryCapabilityGraph.cs b/src/Qyl.OpenTelemetry.AutoInstrumentation/QylTelemetryCapabilityGraph.cs new file mode 100644 index 00000000..777ec867 --- /dev/null +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation/QylTelemetryCapabilityGraph.cs @@ -0,0 +1,25 @@ +namespace Qyl.OpenTelemetry.AutoInstrumentation; + +/// +/// Read-only access to this build's Telemetry Capability Graph (TCG) — the complete, +/// generated, provenance-tagged manifest of every telemetry capability this binary can produce. +/// +/// +/// The TCG is generated deterministically from the instrumentation contract (it is not sampled and +/// cannot drift), so an external entity can learn the full possible OpenTelemetry surface of this +/// binary without observing exported telemetry over time. The document shape and the vendor-neutral +/// exchange schema are described in docs/TELEMETRY_CAPABILITY_GRAPH.md and +/// docs/schema/telemetry-capability-graph.schema.json. Backing data is the generated +/// Internal.QylTelemetryCapabilityGraphData. +/// +public static class QylTelemetryCapabilityGraph +{ + /// The Telemetry Capability Graph serialized as compact JSON (schema ). + public static string Json => Internal.QylTelemetryCapabilityGraphData.Json; + + /// Semantic version of the document format. + public static string SchemaVersion => Internal.QylTelemetryCapabilityGraphData.SchemaVersion; + + /// Number of capabilities declared in . + public static int CapabilityCount => Internal.QylTelemetryCapabilityGraphData.CapabilityCount; +} From 1be35fbbd330d77bcd078040770f4904d67553fd Mon Sep 17 00:00:00 2001 From: ancplua Date: Sun, 28 Jun 2026 23:40:59 +0200 Subject: [PATCH 10/10] fix(tcg): confine precompilation experiment to its lane; robust public TCG accessor Reverts the production precompilation-in-place rewrite that made the package unconsumable on released SDKs (CS9057: the analyzer referenced nightly Roslyn 5.9 vs the in-box SDK compiler 5.6, so the analyzer refused to load -> zero interceptors). Per docs/experiments/precompilation-verdict.md (verdict: DREAMING), the experimental RegisterPreCompilationSourceOutput stays in the isolated experiment/ + spike/ trees only. - Restore Directory.Build.props/Packages.props/nuget.config and the production generators (QylAutoInstrumentationGenerator, InstrumentationContract) to stable Roslyn 5.3.0; delete the in-production QylSemanticContractProducer / QylSemanticTelemetryGenerator and their snapshot fixtures. - Make the TCG public accessor robust: QylTelemetryCapabilityGraph is now a hand-written public partial type whose manifest body the generator fills via a 'partial' (elided if the generator has not run), matching the SemConvRegistry pattern. Fixes the cross-build-context fragility (CS0234 / RS0017) the smoketest caught. Verified: smoketest-ok (pkg + projref, managed + NativeAOT); full solution build 0/0; public-api-baseline-ok; generator-snapshots-ok; real 60-capability manifest bakes. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 6 +- Directory.Build.props | 5 - Directory.Packages.props | 10 +- docs/TELEMETRY_CAPABILITY_GRAPH.md | 5 +- nuget.config | 18 --- .../InstrumentationContract.cs | 29 +++- ...utoInstrumentation.SourceGenerators.csproj | 3 - .../QylAutoInstrumentationGenerator.cs | 66 ++------- .../QylSemanticContractProducer.cs | 127 ------------------ .../QylSemanticTelemetryGenerator.cs | 108 --------------- .../TelemetryCapabilityGraphGenerator.cs | 26 ++-- .../QylTelemetryCapabilityGraph.cs | 31 ++++- .../Fixture/OrderRequest.cs | 9 -- ....SourceGenerators.Snapshots.Fixture.csproj | 5 - .../Fixture/app.qyl-semantic-contract.tsv | 3 - .../QylSemanticBindingAttribute.g.verified.cs | 18 --- .../QylSemanticTelemetry.g.verified.cs | 13 -- ...rderRequest.SemanticContract.g.verified.cs | 7 - tools/verify-contract-invariants.py | 8 +- tools/verify-generator-snapshots.py | 4 - 20 files changed, 76 insertions(+), 425 deletions(-) delete mode 100644 src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylSemanticContractProducer.cs delete mode 100644 src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylSemanticTelemetryGenerator.cs delete mode 100644 tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/OrderRequest.cs delete mode 100644 tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/app.qyl-semantic-contract.tsv delete mode 100644 tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/QylSemanticBindingAttribute.g.verified.cs delete mode 100644 tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/QylSemanticTelemetry.g.verified.cs delete mode 100644 tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/Qyl_Snapshot_Domain_OrderRequest.SemanticContract.g.verified.cs diff --git a/AGENTS.md b/AGENTS.md index 9d2a1101..d72ff6c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,9 +44,9 @@ Three pillars: the declared surface, and CI can treat telemetry as a typed, versioned API. **Status (current tree — do not overstate):** First-Light steps 1–2 are shipped — -`TelemetryCapabilityGraphGenerator` bakes the TCG into the core assembly (generated -`Internal.QylTelemetryCapabilityGraphData`, gated to the core assembly like `SemConvRegistryGenerator`), -exposed publicly as `QylTelemetryCapabilityGraph.Json` / `.SchemaVersion` / `.CapabilityCount` (the +`TelemetryCapabilityGraphGenerator` bakes the TCG into the core assembly's public type +`QylTelemetryCapabilityGraph` (its manifest body filled via a generator `partial`, gated to the core +assembly like `SemConvRegistryGenerator`) — `.Json` / `.SchemaVersion` / `.CapabilityCount` (the queryable surface), with the vendor-neutral exchange schema in `docs/schema/telemetry-capability-graph.schema.json` and `docs/TELEMETRY_CAPABILITY_GRAPH.md`. Next: the OTLP resource-log publication channel at boot (mapping already specified in the exchange spec) — diff --git a/Directory.Build.props b/Directory.Build.props index 71d172d1..f34a395a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -58,11 +58,6 @@ Pack="true" PackagePath="" Visible="false" /> - - diff --git a/Directory.Packages.props b/Directory.Packages.props index 7eac26ef..0e81ca48 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,14 +20,8 @@ - - - - - + + diff --git a/docs/TELEMETRY_CAPABILITY_GRAPH.md b/docs/TELEMETRY_CAPABILITY_GRAPH.md index e5682840..175cd4ee 100644 --- a/docs/TELEMETRY_CAPABILITY_GRAPH.md +++ b/docs/TELEMETRY_CAPABILITY_GRAPH.md @@ -16,8 +16,9 @@ cannot drift from what the binary actually emits. The schema is [`schema/telemetry-capability-graph.schema.json`](schema/telemetry-capability-graph.schema.json) (JSON Schema 2020-12). The producer is `TelemetryCapabilityGraphGenerator`, which bakes the document into the core assembly -as the generated constant `Internal.QylTelemetryCapabilityGraphData.Json`, exposed publicly as -`QylTelemetryCapabilityGraph.Json` (with `.SchemaVersion` and `.CapabilityCount`). +as the public type `QylTelemetryCapabilityGraph` (`.Json` / `.SchemaVersion` / `.CapabilityCount`). +The generator fills the manifest body via a `partial` method that is elided when it has not run, so +the public surface always compiles (a normal core build bakes the real manifest). ## Document shape diff --git a/nuget.config b/nuget.config index 19b8485b..765346e5 100644 --- a/nuget.config +++ b/nuget.config @@ -3,23 +3,5 @@ - - - - - - - - - - - - - - diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/InstrumentationContract.cs b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/InstrumentationContract.cs index 6f23c340..885bf917 100644 --- a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/InstrumentationContract.cs +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/InstrumentationContract.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -315,10 +316,30 @@ public static string EmitGeneratedManifestSource() return builder.ToString(); } - // The implemented-signal gate is no longer an in-process lookup here. It is emitted as the - // pre-compilation QylContractRegistry symbol (RegisterPreCompilationSourceOutput) and bound back - // in the standard phase; see QylAutoInstrumentationGenerator. ImplementedSignalKeys above is its - // single source. + public static InstrumentationContractItem? TryGetImplementedSignal(string key) + => TryGetSignal(key, static item => item.QylStatus is QylContractStatus.Implemented); + + public static InstrumentationContractItem? TryGetSourceInterceptorSignal(string key) + => TryGetSignal(key, static item => item is { QylStatus: QylContractStatus.Implemented, Lane: InstrumentationContractLane.SourceInterceptor }); + + public static InstrumentationContractItem? TryGetSupportedSignal(string key) + => TryGetSignal(key, static _ => true); + + private static InstrumentationContractItem? TryGetSignal(string key, Func predicate) + { + foreach (var item in Items) + { + if (item.Kind is InstrumentationContractKind.SignalSpecificInstrumentationPromise && + predicate(item) && + string.Equals(item.Key, key, StringComparison.Ordinal)) + { + return item; + } + } + + return null; + } + private static void EmitStringArray(StringBuilder builder, string name, IEnumerable values) { builder.Append(" public static string[] "); diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.csproj b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.csproj index 2ffba5e1..bdcf1302 100644 --- a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.csproj +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.csproj @@ -13,9 +13,6 @@ true true $(WarningsAsErrors);CS1591 - - $(NoWarn);RSEXPERIMENTAL007;RS2008 diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylAutoInstrumentationGenerator.cs b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylAutoInstrumentationGenerator.cs index d7787611..c64905aa 100644 --- a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylAutoInstrumentationGenerator.cs +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylAutoInstrumentationGenerator.cs @@ -72,33 +72,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) SourceText.From(InstrumentationContract.EmitGeneratedManifestSource(), Encoding.UTF8)); }); - // EXPERIMENTAL (RSEXPERIMENTAL007): emit the implemented-signal contract gate as a - // compilation-native symbol in the PRE-COMPILATION phase, then bind it back in the standard - // phase. Replaces the in-process InstrumentationContract.TryGetImplementedSignal lookup. - context.RegisterPreCompilationSourceOutput( - context.AdditionalTextsProvider.Collect(), - static (precompilation, _) => precompilation.AddSource( - "QylContractRegistry.g.cs", - SourceText.From(EmitContractRegistrySource(), Encoding.UTF8))); - - // Bind the pre-compilation symbol from the augmented compilation; the compilation is the - // cache. A newline-joined string keeps the pipeline value equatable for incremental reuse. - var implementedContractKeys = context.CompilationProvider.Select(static (compilation, _) => - { - var registry = compilation.GetTypeByMetadataName( - "Qyl.OpenTelemetry.AutoInstrumentation.Generated.QylContractRegistry"); - if (registry is null) - return string.Empty; - - return string.Join( - "\n", - registry.GetMembers() - .OfType() - .Where(static field => field.HasConstantValue && field.ConstantValue is string) - .Select(static field => (string)field.ConstantValue!) - .OrderBy(static key => key, StringComparer.Ordinal)); - }); - var interceptedInvocations = context.SyntaxProvider .CreateSyntaxProvider( static (node, _) => node is InvocationExpressionSyntax, @@ -106,24 +79,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Where(static invocation => invocation is not null) .Collect(); - context.RegisterSourceOutput(interceptedInvocations.Combine(implementedContractKeys), EmitInterceptors); - } - - private static string EmitContractRegistrySource() - { - var builder = new StringBuilder(); - builder.AppendLine("// EXPERIMENTAL pre-compilation phase (RSEXPERIMENTAL007)."); - builder.AppendLine("namespace Qyl.OpenTelemetry.AutoInstrumentation.Generated;"); - builder.AppendLine("internal static class QylContractRegistry"); - builder.AppendLine("{"); - - var index = 0; - foreach (var key in InstrumentationContract.ImplementedSignalKeys) - builder.AppendLine($" public const string ImplementedKey{index++} = \"{key}\";"); - - builder.AppendLine($" public const int ImplementedKeyCount = {InstrumentationContract.ImplementedSignalKeys.Length};"); - builder.AppendLine("}"); - return builder.ToString(); + context.RegisterSourceOutput(interceptedInvocations, EmitInterceptors); } private static InterceptedInvocation? TryCreateInterceptedInvocation( @@ -145,9 +101,9 @@ private static string EmitContractRegistrySource() if (!TryGetInvocation(symbol, receiverType, out var target)) return null; - // Contract gating moved to emission time (EmitInterceptors), where the pre-compilation - // QylContractRegistry symbol is bound. Every structurally-matched target now flows through - // the pipeline; the contract filter is applied once the bound key set is available. + if (!IsSupportedTarget(target)) + return null; + var interceptableLocation = context.SemanticModel.GetInterceptableLocation(invocation, cancellationToken); if (interceptableLocation is null) return null; @@ -155,9 +111,9 @@ private static string EmitContractRegistrySource() return new InterceptedInvocation(target, interceptableLocation); } - private static bool IsSupportedTarget(in InterceptorTarget target, HashSet implementedKeys) + private static bool IsSupportedTarget(in InterceptorTarget target) { - if (!implementedKeys.Contains(target.ContractKey)) + if (InstrumentationContract.TryGetImplementedSignal(target.ContractKey) is null) return false; if (target.AdditionalContractKeys is not { Length: > 0 } additionalContractKeys) @@ -165,7 +121,7 @@ private static bool IsSupportedTarget(in InterceptorTarget target, HashSet Invocations, string ImplementedKeysJoined) input) + ImmutableArray nullableInvocations) { - var nullableInvocations = input.Invocations; if (nullableInvocations.IsDefaultOrEmpty) return; - var implementedKeys = input.ImplementedKeysJoined.Length is 0 - ? new HashSet(StringComparer.Ordinal) - : new HashSet(input.ImplementedKeysJoined.Split('\n'), StringComparer.Ordinal); - var invocations = nullableInvocations .Where(static invocation => invocation is not null) .Select(static invocation => invocation!.Value) - .Where(invocation => IsSupportedTarget(invocation.Target, implementedKeys)) .Distinct() // Stable, content-based ordering so the emission order and the _N interceptor-name indices // are a pure function of the matched call sites — independent of Roslyn's cross-tree syntax diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylSemanticContractProducer.cs b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylSemanticContractProducer.cs deleted file mode 100644 index 0fad135a..00000000 --- a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylSemanticContractProducer.cs +++ /dev/null @@ -1,127 +0,0 @@ -using ANcpLua.Roslyn.Utilities; -using System.Text; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; - -namespace Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators; - -/// -/// Semantic-contract PRODUCER. Reads *.qyl-semantic-contract.tsv additional files and emits, -/// via RegisterPreCompilationSourceOutput (RSEXPERIMENTAL007), the shared -/// [QylSemanticBinding(property, attribute)] attribute plus attribute-decorated -/// partials of the user's types into the INITIAL compilation. -/// -/// Because the output lands in the initial compilation, it is visible to every other -/// generator's standard phase — qyl's own is the first -/// consumer, and third-party generators (logging, metrics, validation) can bind the same contract -/// without referencing qyl. Within qyl's own assembly the split is redundant; it exists to publish -/// the contract across the generator boundary. -/// -/// Inert by design: with no contract additional file (every existing demo), it emits nothing, -/// so it cannot affect projects that do not opt in. -/// -[Generator(LanguageNames.CSharp)] -public sealed class QylSemanticContractProducer : IIncrementalGenerator -{ - internal const string ContractFileSuffix = "qyl-semantic-contract.tsv"; - internal const string GeneratedNamespace = "Qyl.OpenTelemetry.AutoInstrumentation.Generated"; - internal const string BindingAttributeMetadataName = GeneratedNamespace + ".QylSemanticBindingAttribute"; - - /// - public void Initialize(IncrementalGeneratorInitializationContext context) - { - var contract = context.AdditionalTextsProvider - .Where(static text => text.Path.Replace('\\', '/').EndsWithIgnoreCase(ContractFileSuffix)) - .Select(static (text, cancellationToken) => text.GetText(cancellationToken)?.ToString() ?? string.Empty) - .Collect(); - - context.RegisterPreCompilationSourceOutput(contract, static (precompilation, texts) => - { - var rows = ParseContract(string.Concat(texts)); - if (rows.Count is 0) - return; - - precompilation.AddSource("QylSemanticBindingAttribute.g.cs", SourceText.From(AttributeSource, Encoding.UTF8)); - - foreach (var byType in rows.GroupBy(static row => row.TypeFqn, StringComparer.Ordinal)) - { - precompilation.AddSource( - byType.Key.Replace('.', '_') + ".SemanticContract.g.cs", - SourceText.From(BuildContractPartial(byType.Key, byType.ToList()), Encoding.UTF8)); - } - }); - } - - private const string AttributeSource = -""" -// shared semantic-contract vocabulary (pre-compilation phase). -#nullable enable -namespace Qyl.OpenTelemetry.AutoInstrumentation.Generated; - -/// Binds a property of the annotated type to an OpenTelemetry attribute key. Emitted into -/// the initial compilation so any generator can read it from the semantic model. -[global::System.AttributeUsage(global::System.AttributeTargets.Class, AllowMultiple = true, Inherited = false)] -internal sealed class QylSemanticBindingAttribute : global::System.Attribute -{ - public QylSemanticBindingAttribute(string property, string attribute) - { - Property = property; - Attribute = attribute; - } - - public string Property { get; } - public string Attribute { get; } -} -"""; - - private readonly record struct ContractRow(string TypeFqn, string Property, string Attribute); - - private static List ParseContract(string tsv) - { - var rows = new List(); - foreach (var rawLine in tsv.Split('\n')) - { - var line = rawLine.TrimEnd('\r'); - if (line.Length is 0 || line[0] is '#') - continue; - - var columns = line.Split('\t'); - if (columns.Length < 3) - continue; - - rows.Add(new ContractRow(columns[0].Trim(), columns[1].Trim(), columns[2].Trim())); - } - - return rows; - } - - private static string BuildContractPartial(string typeFqn, List rows) - { - // Top-level types only: a dotted FQN cannot encode nested-type ('Outer+Inner') containment. - var lastDot = typeFqn.LastIndexOf('.'); - var namespaceName = lastDot < 0 ? null : typeFqn.Substring(0, lastDot); - var typeName = lastDot < 0 ? typeFqn : typeFqn.Substring(lastDot + 1); - - var builder = new StringBuilder(); - builder.AppendLine("// compiler-visible semantic contract (pre-compilation phase)."); - builder.AppendLine("#nullable enable"); - if (namespaceName is not null) - { - builder.AppendLine($"namespace {namespaceName};"); - builder.AppendLine(); - } - - foreach (var row in rows) - { - builder.AppendLine( - $"[global::{GeneratedNamespace}.QylSemanticBindingAttribute(\"{Escape(row.Property)}\", \"{Escape(row.Attribute)}\")]"); - } - - // No access modifier: a partial part without one merges with a public/internal user part - // without an accessibility conflict. The user's type must be declared `partial class`. - builder.AppendLine($"partial class {typeName} {{ }}"); - return builder.ToString(); - } - - private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); -} diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylSemanticTelemetryGenerator.cs b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylSemanticTelemetryGenerator.cs deleted file mode 100644 index 754fb215..00000000 --- a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/QylSemanticTelemetryGenerator.cs +++ /dev/null @@ -1,108 +0,0 @@ -using ANcpLua.Roslyn.Utilities; -using System.Text; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; - -namespace Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators; - -/// -/// Telemetry CONSUMER — the first consumer of the contract. -/// In its standard phase it binds the pre-compilation [QylSemanticBinding] attributes (visible -/// because they were emitted into the initial compilation) and emits an AOT-safe recorder per bound -/// type: pure activity.SetTag(...), no reflection, no IL rewrite. The generated telemetry is -/// compiled into the application. -/// -/// Inert by design: with no contract (no [QylSemanticBinding] types in the compilation) -/// it emits nothing. -/// -[Generator(LanguageNames.CSharp)] -public sealed class QylSemanticTelemetryGenerator : IIncrementalGenerator -{ - /// - public void Initialize(IncrementalGeneratorInitializationContext context) - { - context.RegisterSourceOutput(context.CompilationProvider, static (context, compilation) => - { - var marker = compilation.GetTypeByMetadataName(QylSemanticContractProducer.BindingAttributeMetadataName); - if (marker is null) - return; - - var boundTypes = CollectBoundTypes(compilation, marker); - if (boundTypes.Count is 0) - return; - - context.AddSource("QylSemanticTelemetry.g.cs", SourceText.From(EmitRecorders(boundTypes), Encoding.UTF8)); - }); - } - - private readonly record struct BoundType(string TypeFqn, string SimpleName, IReadOnlyList<(string Property, string Attribute)> Bindings); - - private static List CollectBoundTypes(Compilation compilation, INamedTypeSymbol marker) - { - var result = new List(); - foreach (var type in EnumerateSourceTypes(compilation.GlobalNamespace)) - { - var bindings = new List<(string Property, string Attribute)>(); - foreach (var attribute in type.GetAttributes()) - { - if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, marker) && - attribute.GetConstructorArgument(0) is { } property && - attribute.GetConstructorArgument(1) is { } semanticAttribute) - { - bindings.Add((property, semanticAttribute)); - } - } - - if (bindings.Count > 0) - { - result.Add(new BoundType( - type.ToDisplayString(), - type.Name, - bindings.OrderBy(static binding => binding.Property, StringComparer.Ordinal).ToList())); - } - } - - return result.OrderBy(static bound => bound.TypeFqn, StringComparer.Ordinal).ToList(); - } - - private static IEnumerable EnumerateSourceTypes(INamespaceSymbol namespaceSymbol) - { - foreach (var type in namespaceSymbol.GetTypeMembers()) - { - if (type.Locations.Any(static location => location.IsInSource)) - yield return type; - } - - foreach (var child in namespaceSymbol.GetNamespaceMembers()) - { - foreach (var type in EnumerateSourceTypes(child)) - yield return type; - } - } - - private static string EmitRecorders(List boundTypes) - { - var builder = new StringBuilder(); - builder.AppendLine("// AOT-safe semantic telemetry generated from the qyl semantic contract."); - builder.AppendLine("#nullable enable"); - builder.AppendLine("namespace Qyl.OpenTelemetry.AutoInstrumentation.Generated;"); - - foreach (var bound in boundTypes) - { - builder.AppendLine($"/// Records OpenTelemetry semantic-convention tags for . Pure SetTag; no reflection."); - builder.AppendLine($"internal static class {bound.SimpleName}Telemetry"); - builder.AppendLine("{"); - builder.AppendLine($" public static void Record(global::System.Diagnostics.Activity? activity, global::{bound.TypeFqn} value)"); - builder.AppendLine(" {"); - builder.AppendLine(" if (activity is null) return;"); - foreach (var (property, attribute) in bound.Bindings) - builder.AppendLine($" activity.SetTag(\"{Escape(attribute)}\", value.{property});"); - builder.AppendLine(" }"); - builder.AppendLine("}"); - } - - return builder.ToString(); - } - - private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); -} diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/TelemetryCapabilityGraphGenerator.cs b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/TelemetryCapabilityGraphGenerator.cs index 6a5836dd..bbb54de7 100644 --- a/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/TelemetryCapabilityGraphGenerator.cs +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/TelemetryCapabilityGraphGenerator.cs @@ -55,29 +55,19 @@ private static string EmitSource() var builder = new StringBuilder(); builder.AppendLine("// "); builder.AppendLine("#nullable enable"); - builder.AppendLine("namespace Qyl.OpenTelemetry.AutoInstrumentation.Internal;"); + builder.AppendLine("namespace Qyl.OpenTelemetry.AutoInstrumentation;"); builder.AppendLine(); - builder.AppendLine("/// "); - builder.AppendLine("/// Generated backing data for the qyl Telemetry Capability Graph (TCG): the complete,"); - builder.AppendLine("/// machine-readable manifest of every telemetry capability this build can produce."); - builder.AppendLine("/// Public access is through Qyl.OpenTelemetry.AutoInstrumentation.QylTelemetryCapabilityGraph."); - builder.AppendLine("/// "); - builder.AppendLine("internal static class QylTelemetryCapabilityGraphData"); + builder.AppendLine("public static partial class QylTelemetryCapabilityGraph"); builder.AppendLine("{"); - builder.AppendLine(" /// Schema version of the emitted manifest."); - builder.Append(" public const string SchemaVersion = "); - AppendCSharpStringLiteral(builder, SchemaVersion); + builder.AppendLine(" static partial void Contribute(ref string json, ref int capabilityCount)"); + builder.AppendLine(" {"); + builder.Append(" json = "); + AppendCSharpStringLiteral(builder, BuildManifestJson()); builder.AppendLine(";"); - builder.AppendLine(); - builder.AppendLine(" /// Number of capabilities declared in the manifest."); builder.AppendLine( - " public const int CapabilityCount = " + + " capabilityCount = " + InstrumentationContract.Items.Length.ToString(CultureInfo.InvariantCulture) + ";"); - builder.AppendLine(); - builder.AppendLine(" /// The Telemetry Capability Graph serialized as compact JSON."); - builder.Append(" public const string Json = "); - AppendCSharpStringLiteral(builder, BuildManifestJson()); - builder.AppendLine(";"); + builder.AppendLine(" }"); builder.AppendLine("}"); return builder.ToString(); } diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation/QylTelemetryCapabilityGraph.cs b/src/Qyl.OpenTelemetry.AutoInstrumentation/QylTelemetryCapabilityGraph.cs index 777ec867..4db295be 100644 --- a/src/Qyl.OpenTelemetry.AutoInstrumentation/QylTelemetryCapabilityGraph.cs +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation/QylTelemetryCapabilityGraph.cs @@ -9,17 +9,36 @@ namespace Qyl.OpenTelemetry.AutoInstrumentation; /// cannot drift), so an external entity can learn the full possible OpenTelemetry surface of this /// binary without observing exported telemetry over time. The document shape and the vendor-neutral /// exchange schema are described in docs/TELEMETRY_CAPABILITY_GRAPH.md and -/// docs/schema/telemetry-capability-graph.schema.json. Backing data is the generated -/// Internal.QylTelemetryCapabilityGraphData. +/// docs/schema/telemetry-capability-graph.schema.json. +/// +/// +/// The manifest body is filled by TelemetryCapabilityGraphGenerator via the generated +/// partial. That partial is elided when the generator does not run, so this +/// public surface always compiles; in that degraded case is "{}" and +/// is 0. A normal build of the core assembly bakes the real +/// manifest. +/// /// -public static class QylTelemetryCapabilityGraph +public static partial class QylTelemetryCapabilityGraph { + private static readonly (string Json, int CapabilityCount) Data = Build(); + /// The Telemetry Capability Graph serialized as compact JSON (schema ). - public static string Json => Internal.QylTelemetryCapabilityGraphData.Json; + public static string Json => Data.Json; /// Semantic version of the document format. - public static string SchemaVersion => Internal.QylTelemetryCapabilityGraphData.SchemaVersion; + public static string SchemaVersion => "0.1.0-draft"; /// Number of capabilities declared in . - public static int CapabilityCount => Internal.QylTelemetryCapabilityGraphData.CapabilityCount; + public static int CapabilityCount => Data.CapabilityCount; + + static partial void Contribute(ref string json, ref int capabilityCount); + + private static (string Json, int CapabilityCount) Build() + { + var json = "{}"; + var capabilityCount = 0; + Contribute(ref json, ref capabilityCount); + return (json, capabilityCount); + } } diff --git a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/OrderRequest.cs b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/OrderRequest.cs deleted file mode 100644 index 048bad98..00000000 --- a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/OrderRequest.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Qyl.Snapshot.Domain; - -// Partial so the producer's pre-compilation [QylSemanticBinding] contract part merges into this type. -// The user writes no attributes; they come from app.qyl-semantic-contract.tsv via the producer. -public partial class OrderRequest -{ - public string CustomerId { get; init; } = ""; - public string OrderId { get; init; } = ""; -} diff --git a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots.Fixture.csproj b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots.Fixture.csproj index ba2ae188..f697e8ed 100644 --- a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots.Fixture.csproj +++ b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots.Fixture.csproj @@ -18,10 +18,5 @@ - - - - - diff --git a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/app.qyl-semantic-contract.tsv b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/app.qyl-semantic-contract.tsv deleted file mode 100644 index 2fb70b37..00000000 --- a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/app.qyl-semantic-contract.tsv +++ /dev/null @@ -1,3 +0,0 @@ -# Fully-qualified type Property semantic-convention attribute -Qyl.Snapshot.Domain.OrderRequest CustomerId customer.id -Qyl.Snapshot.Domain.OrderRequest OrderId order.id diff --git a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/QylSemanticBindingAttribute.g.verified.cs b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/QylSemanticBindingAttribute.g.verified.cs deleted file mode 100644 index b9e79f22..00000000 --- a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/QylSemanticBindingAttribute.g.verified.cs +++ /dev/null @@ -1,18 +0,0 @@ -// shared semantic-contract vocabulary (pre-compilation phase). -#nullable enable -namespace Qyl.OpenTelemetry.AutoInstrumentation.Generated; - -/// Binds a property of the annotated type to an OpenTelemetry attribute key. Emitted into -/// the initial compilation so any generator can read it from the semantic model. -[global::System.AttributeUsage(global::System.AttributeTargets.Class, AllowMultiple = true, Inherited = false)] -internal sealed class QylSemanticBindingAttribute : global::System.Attribute -{ - public QylSemanticBindingAttribute(string property, string attribute) - { - Property = property; - Attribute = attribute; - } - - public string Property { get; } - public string Attribute { get; } -} \ No newline at end of file diff --git a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/QylSemanticTelemetry.g.verified.cs b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/QylSemanticTelemetry.g.verified.cs deleted file mode 100644 index d7cdef4b..00000000 --- a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/QylSemanticTelemetry.g.verified.cs +++ /dev/null @@ -1,13 +0,0 @@ -// AOT-safe semantic telemetry generated from the qyl semantic contract. -#nullable enable -namespace Qyl.OpenTelemetry.AutoInstrumentation.Generated; -/// Records OpenTelemetry semantic-convention tags for . Pure SetTag; no reflection. -internal static class OrderRequestTelemetry -{ - public static void Record(global::System.Diagnostics.Activity? activity, global::Qyl.Snapshot.Domain.OrderRequest value) - { - if (activity is null) return; - activity.SetTag("customer.id", value.CustomerId); - activity.SetTag("order.id", value.OrderId); - } -} diff --git a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/Qyl_Snapshot_Domain_OrderRequest.SemanticContract.g.verified.cs b/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/Qyl_Snapshot_Domain_OrderRequest.SemanticContract.g.verified.cs deleted file mode 100644 index 168e1ebc..00000000 --- a/tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/verified/Qyl_Snapshot_Domain_OrderRequest.SemanticContract.g.verified.cs +++ /dev/null @@ -1,7 +0,0 @@ -// compiler-visible semantic contract (pre-compilation phase). -#nullable enable -namespace Qyl.Snapshot.Domain; - -[global::Qyl.OpenTelemetry.AutoInstrumentation.Generated.QylSemanticBindingAttribute("CustomerId", "customer.id")] -[global::Qyl.OpenTelemetry.AutoInstrumentation.Generated.QylSemanticBindingAttribute("OrderId", "order.id")] -partial class OrderRequest { } diff --git a/tools/verify-contract-invariants.py b/tools/verify-contract-invariants.py index 7fb56642..214e07d2 100644 --- a/tools/verify-contract-invariants.py +++ b/tools/verify-contract-invariants.py @@ -1797,12 +1797,8 @@ def verify_generator_keys(artifacts: ModuleType, contract: dict[str, Any]) -> No "SourceInterceptorSignalKeys", "RuntimePublicTelemetrySignalKeys", "UnsupportedNativeAotSignalKeys", - # The implemented-signal gate is no longer an in-process TryGet* lookup; it is emitted as the - # pre-compilation QylContractRegistry symbol and bound back in the standard phase. Assert the - # mechanism (in the generator) instead of the deleted helpers. - "RegisterPreCompilationSourceOutput", - "QylContractRegistry", - "GetTypeByMetadataName", + "TryGetSourceInterceptorSignal", + "TryGetImplementedSignal", "InterceptorEmissionDescriptor", "InterceptorMethodShape", "InterceptorSignalOwnership", diff --git a/tools/verify-generator-snapshots.py b/tools/verify-generator-snapshots.py index fca7b6a3..eb75b3f0 100644 --- a/tools/verify-generator-snapshots.py +++ b/tools/verify-generator-snapshots.py @@ -22,10 +22,6 @@ EXPECTED_FILES = { "QylAutoInstrumentation.Interceptors.g.verified.cs": "QylAutoInstrumentation.Interceptors.g.cs", "QylGeneratedInstrumentationContract.g.verified.cs": "QylGeneratedInstrumentationContract.g.cs", - # Semantic-contract producer + telemetry consumer (opt-in via app.qyl-semantic-contract.tsv). - "QylSemanticBindingAttribute.g.verified.cs": "QylSemanticBindingAttribute.g.cs", - "Qyl_Snapshot_Domain_OrderRequest.SemanticContract.g.verified.cs": "Qyl_Snapshot_Domain_OrderRequest.SemanticContract.g.cs", - "QylSemanticTelemetry.g.verified.cs": "QylSemanticTelemetry.g.cs", } REQUIRED_INTERCEPTOR_TOKENS = [