diff --git a/.github/sourcelink/Directory.Build.props b/.github/sourcelink/Directory.Build.props
new file mode 100644
index 0000000..29a7d83
--- /dev/null
+++ b/.github/sourcelink/Directory.Build.props
@@ -0,0 +1,9 @@
+
+
+
diff --git a/.github/sourcelink/Directory.Build.targets b/.github/sourcelink/Directory.Build.targets
new file mode 100644
index 0000000..2e60f34
--- /dev/null
+++ b/.github/sourcelink/Directory.Build.targets
@@ -0,0 +1,3 @@
+
+
+
diff --git a/.github/sourcelink/README.md b/.github/sourcelink/README.md
new file mode 100644
index 0000000..5aad8e3
--- /dev/null
+++ b/.github/sourcelink/README.md
@@ -0,0 +1,51 @@
+# SourceLink step-into verification
+
+Fixtures for [`sourcelink-stepinto.yaml`](../workflows/sourcelink-stepinto.yaml),
+which proves the *debugger* half of SourceLink end-to-end: that pressing **F11**
+in a consumer steps into the library's **real source** (resolved via the
+SourceLink map in the PDB), not a decompiled placeholder.
+
+This complements [`sourcelink.yaml`](../workflows/sourcelink.yaml) (PR #208), which
+proves every document in the PDB resolves to real GitHub content via
+`dotnet sourcelink test`. Together they cover the full chain F11 depends on and
+satisfy #133: SourceLink map in the PDB → source-file resolution → GitHub raw-URL
+fetch.
+
+## How it works
+
+1. Build [`consumer/`](consumer/) with `-c Debug -p:ContinuousIntegrationBuild=true`.
+ Its `ProjectReference` compiles the library with the **same SourceLink map a
+ released package ships** (deterministic `/_/…` source roots that map to GitHub
+ raw URLs), and Debug/non-optimized codegen so the step-into target isn't
+ reordered away.
+2. Run [`verify_stepinto.py`](verify_stepinto.py) — it drives
+ [netcoredbg](https://github.com/Samsung/netcoredbg) over its MI interface to
+ break in the consumer, step into `TestExtractor`'s constructor, and assert
+ the resulting frame is the library's SourceLink-mapped source file with symbols
+ loaded. If SourceLink or the sequence points were broken the step would land
+ with no source and the script exits non-zero.
+
+## Why a ProjectReference, not the packed NuGet
+
+netcoredbg is the only CI-scriptable .NET debugger, and it does **not** reliably
+pair a *package*-sourced assembly with its symbol-package (`.snupkg`) PDB — it
+reports `symbols-loaded=0` and cannot step into it (verified during development).
+A `ProjectReference` built with `ContinuousIntegrationBuild=true` produces a
+**byte-identical SourceLink PDB**, so the SourceLink map under test is the same
+one that ships; only the delivery path differs. The packed-package symbol/URL
+resolution is covered by `sourcelink.yaml` (PR #208).
+
+## Files
+
+- `consumer/StepIntoConsumer.csproj` / `Program.cs` — the fixture consumer. The
+ break line is marked `STEP_INTO_TARGET`.
+- `Directory.Build.props` / `.targets` — empty isolation stubs so the consumer
+ does **not** inherit the repo's analyzers / BannedSymbols / multi-TFM policy.
+- `verify_stepinto.py` — the debugger driver (exit 0 = step-into resolved real
+ source).
+
+## Scope
+
+Scheduled + manual, not a PR gate — debugger automation is heavier and slightly
+less deterministic than a unit test, and SourceLink's raw URLs resolve only
+against a pushed commit.
diff --git a/.github/sourcelink/consumer/Program.cs b/.github/sourcelink/consumer/Program.cs
new file mode 100644
index 0000000..2326c64
--- /dev/null
+++ b/.github/sourcelink/consumer/Program.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using Wolfgang.Etl.TestKit;
+
+// End-to-end SourceLink "step into" fixture. The debugger sets a breakpoint on
+// the line marked STEP_INTO_TARGET and issues a step-into (the F11 a consumer
+// would press). If SourceLink is intact the debugger resolves the library's real
+// source (from GitHub) at the constructor below, instead of a decompiled
+// placeholder. TestExtractor's constructor is a plain, non-async method, which
+// makes it a clean and stable step-into target.
+
+IEnumerable items = new[] { 1, 2, 3 };
+var extractor = new TestExtractor(items); // STEP_INTO_TARGET
+Console.WriteLine(extractor.GetType().FullName);
diff --git a/.github/sourcelink/consumer/StepIntoConsumer.csproj b/.github/sourcelink/consumer/StepIntoConsumer.csproj
new file mode 100644
index 0000000..7f533e3
--- /dev/null
+++ b/.github/sourcelink/consumer/StepIntoConsumer.csproj
@@ -0,0 +1,29 @@
+
+
+
+
+ Exe
+ net10.0
+ enable
+ disable
+
+ false
+ portable
+
+ ../../../src/Wolfgang.Etl.TestKit/Wolfgang.Etl.TestKit.csproj
+
+
+
+
+
+
+
diff --git a/.github/sourcelink/verify_stepinto.py b/.github/sourcelink/verify_stepinto.py
new file mode 100644
index 0000000..ab76c5b
--- /dev/null
+++ b/.github/sourcelink/verify_stepinto.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+"""Drive netcoredbg to prove SourceLink "step into" (F11) works end-to-end.
+
+Sets a breakpoint in the consumer, runs to it, issues a step-into, and inspects
+the resulting stack frame. Succeeds (exit 0) only if the step landed in the
+expected library source file with the library's symbols loaded — i.e. a debugger
+resolves real library source, not a decompiled placeholder. If SourceLink or the
+symbol package were broken, the step would land with no source mapping and this
+fails.
+
+Usage:
+ verify_stepinto.py
+"""
+import subprocess
+import sys
+import re
+import threading
+import queue
+import time
+
+if len(sys.argv) != 5:
+ print(__doc__)
+ sys.exit(2)
+
+debugger, consumer_dll, break_spec, expected_src = sys.argv[1:5]
+
+proc = subprocess.Popen(
+ [debugger, "--interpreter=mi", "--", "dotnet", consumer_dll],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+)
+
+events: "queue.Queue[str]" = queue.Queue()
+
+
+def _reader():
+ for line in proc.stdout:
+ events.put(line.rstrip("\n"))
+ events.put(None)
+
+
+threading.Thread(target=_reader, daemon=True).start()
+
+
+def send(command):
+ print(">>>", command, flush=True)
+ proc.stdin.write(command + "\n")
+ proc.stdin.flush()
+
+
+def wait_for(needles, timeout=60):
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ try:
+ line = events.get(timeout=max(0.1, deadline - time.time()))
+ except queue.Empty:
+ return None
+ if line is None:
+ return None
+ print("dbg>", line, flush=True)
+ if any(n in line for n in needles):
+ return line
+ return None
+
+
+def fail(reason):
+ print("RESULT=FAIL reason=" + reason, flush=True)
+ try:
+ send("-gdb-exit")
+ except Exception:
+ pass
+ sys.exit(1)
+
+
+send("-break-insert " + break_spec)
+wait_for(["^done", "^error"], 15)
+send("-exec-run")
+
+# netcoredbg halts at the managed entry point first; continue to the breakpoint.
+stop = wait_for(["*stopped"], 60)
+if stop and 'reason="entry-point-hit"' in stop:
+ send("-exec-continue")
+ stop = wait_for(["*stopped"], 60)
+
+if not stop or "breakpoint-hit" not in stop:
+ fail("breakpoint-not-hit")
+
+# Step into (F11). Retry a couple of times in case the first step stays on the
+# call line before descending into the callee.
+resolved = None
+for _ in range(4):
+ send("-exec-step")
+ stop = wait_for(["*stopped"], 30)
+ if not stop:
+ break
+ match = re.search(r'frame=\{[^}]*?file="([^"]+)"[^}]*?fullname="([^"]+)"', stop)
+ if match and match.group(1).endswith(expected_src):
+ resolved = (match.group(1), match.group(2), stop)
+ break
+
+if not resolved:
+ fail("did-not-step-into-" + expected_src)
+
+send("-gdb-exit")
+
+source_file, fullname, stop = resolved
+line_match = re.search(r'line="(\d+)"', stop)
+print("RESOLVED_FILE=" + source_file, flush=True)
+print("RESOLVED_FULLNAME=" + fullname, flush=True)
+print("RESOLVED_LINE=" + (line_match.group(1) if line_match else "?"), flush=True)
+print("RESULT=PASS", flush=True)
+sys.exit(0)
diff --git a/.github/workflows/sourcelink-stepinto.yaml b/.github/workflows/sourcelink-stepinto.yaml
new file mode 100644
index 0000000..593867d
--- /dev/null
+++ b/.github/workflows/sourcelink-stepinto.yaml
@@ -0,0 +1,99 @@
+name: SourceLink Step-Into
+
+# End-to-end "F11 into library source" verification, one step beyond sourcelink.yaml.
+#
+# sourcelink.yaml (PR #208) proves every document in the PDB resolves to real
+# GitHub content via `dotnet sourcelink test`. This workflow proves the *debugger*
+# half: it builds a scratch consumer against the library (compiled with the same
+# ContinuousIntegrationBuild + SourceLink a release ships), then drives netcoredbg
+# to set a breakpoint in the consumer and STEP INTO (the F11 a consumer would
+# press) a library constructor — asserting the debugger lands in the library's
+# real source (symbols loaded, SourceLink-mapped source file) rather than a
+# decompiled placeholder. If the SourceLink map or sequence points were broken the
+# step would land with no source and this fails.
+#
+# Why a project reference, not the packed NuGet: netcoredbg (the only
+# CI-scriptable .NET debugger) does not reliably pair a *package*-sourced
+# assembly with its symbol-package PDB, so it cannot step into it. The PDB under
+# test is byte-identical either way; the packed-package symbol/URL resolution is
+# covered by sourcelink.yaml (PR #208). See .github/sourcelink/README.md.
+#
+# Together these two workflows satisfy #133 (SourceLink debug-step-into
+# verification). Scheduled + manual only (not a PR gate): debugger automation is
+# heavier and slightly less deterministic than a unit test, and SourceLink's raw
+# URLs resolve only against a pushed commit.
+
+on:
+ schedule:
+ - cron: '30 7 * * 1' # Mondays 07:30 UTC
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sourcelink-stepinto-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ NETCOREDBG_VERSION: 3.1.2-1054
+ # Reduce tiered-JIT reordering so the step-into target stays stable.
+ DOTNET_TieredCompilation: '0'
+
+jobs:
+ step-into:
+ name: Debugger step-into (F11) resolves library source
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
+ with:
+ persist-credentials: false
+
+ - name: Detect src project
+ id: detect
+ run: |
+ if git ls-files 'src/**/*.csproj' 'src/*.csproj' | grep -q .; then
+ echo "found=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "No src/**/*.csproj found — skipping."
+ echo "found=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Setup .NET
+ if: steps.detect.outputs.found == 'true'
+ uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5
+ with:
+ dotnet-version: '10.0.x'
+
+ # Build the fixture consumer. Its ProjectReference pulls in the library,
+ # compiled with ContinuousIntegrationBuild=true so the PDB carries the same
+ # SourceLink map a released package ships (Debug = non-optimized so the
+ # step-into target isn't reordered away).
+ - name: Build step-into consumer (SourceLink)
+ if: steps.detect.outputs.found == 'true'
+ run: |
+ dotnet build .github/sourcelink/consumer/StepIntoConsumer.csproj \
+ -c Debug \
+ -p:ContinuousIntegrationBuild=true
+
+ - name: Install netcoredbg
+ if: steps.detect.outputs.found == 'true'
+ run: |
+ url="https://github.com/Samsung/netcoredbg/releases/download/${NETCOREDBG_VERSION}/netcoredbg-linux-amd64.tar.gz"
+ curl -sSfL "$url" -o netcoredbg.tar.gz
+ tar -xzf netcoredbg.tar.gz
+ echo "NETCOREDBG=$PWD/netcoredbg/netcoredbg" >> "$GITHUB_ENV"
+
+ - name: Step into the library (F11) and assert real source resolves
+ if: steps.detect.outputs.found == 'true'
+ run: |
+ prog=".github/sourcelink/consumer/Program.cs"
+ line="$(grep -n 'STEP_INTO_TARGET' "$prog" | head -n1 | cut -d: -f1)"
+ echo "Breaking at Program.cs:$line"
+ python3 .github/sourcelink/verify_stepinto.py \
+ "$NETCOREDBG" \
+ ".github/sourcelink/consumer/bin/Debug/net10.0/StepIntoConsumer.dll" \
+ "Program.cs:$line" \
+ "TestExtractor.cs"