From 7ebd036e88790ed4805e386b2244d083836ae69a Mon Sep 17 00:00:00 2001
From: colwill <62014134+colwill@users.noreply.github.com>
Date: Sun, 11 Oct 2026 12:17:17 +0100
Subject: [PATCH] feat: instrumentation first pass
---
.ccc/ccc-surface.json | 2 +-
.gitignore | 1 +
AGENTS.md | 1 +
README.md | 12 +-
docs/Advanced.md | 7 +-
docs/CLI.md | 46 +-
docs/Instrumentation.md | 197 ++++++
extensions/vscode/README.md | 2 +-
extensions/vscode/src/hover.ts | 9 +-
extensions/vscode/src/model.ts | 3 +
extensions/vscode/src/types.ts | 2 +
probes/cpp/ccc_probe.hpp | 382 +++++++++++
probes/cpp/ccc_probe_grpc.hpp | 82 +++
probes/cpp/ccc_probe_httplib.hpp | 39 ++
probes/go/cccprobe.go | 394 ++++++++++++
probes/go/cccprobegrpc/cccprobegrpc.go | 61 ++
probes/go/commit.go | 5 +
probes/python/ccc_probe.py | 646 +++++++++++++++++++
probes/rust/Cargo.toml | 19 +
probes/rust/lib.rs | 408 ++++++++++++
probes/typescript/index.d.ts | 22 +
probes/typescript/index.js | 360 +++++++++++
probes/typescript/index.mjs | 5 +
src/changes.rs | 6 +-
src/coverage.rs | 6 +-
src/edit.rs | 287 ++++++++-
src/insights.rs | 27 +-
src/lib.rs | 1 +
src/main.rs | 38 ++
src/observed.rs | 49 ++
src/probe.rs | 835 +++++++++++++++++++++++++
src/probe/cpp.rs | 378 +++++++++++
src/probe/go.rs | 450 +++++++++++++
src/probe/python.rs | 183 ++++++
src/probe/rust.rs | 434 +++++++++++++
src/probe/typescript.rs | 215 +++++++
src/scan.rs | 4 +-
src/serve.rs | 64 +-
src/violations.rs | 2 +-
39 files changed, 5626 insertions(+), 58 deletions(-)
create mode 100644 docs/Instrumentation.md
create mode 100644 probes/cpp/ccc_probe.hpp
create mode 100644 probes/cpp/ccc_probe_grpc.hpp
create mode 100644 probes/cpp/ccc_probe_httplib.hpp
create mode 100644 probes/go/cccprobe.go
create mode 100644 probes/go/cccprobegrpc/cccprobegrpc.go
create mode 100644 probes/go/commit.go
create mode 100644 probes/python/ccc_probe.py
create mode 100644 probes/rust/Cargo.toml
create mode 100644 probes/rust/lib.rs
create mode 100644 probes/typescript/index.d.ts
create mode 100644 probes/typescript/index.js
create mode 100644 probes/typescript/index.mjs
create mode 100644 src/probe.rs
create mode 100644 src/probe/cpp.rs
create mode 100644 src/probe/go.rs
create mode 100644 src/probe/python.rs
create mode 100644 src/probe/rust.rs
create mode 100644 src/probe/typescript.rs
diff --git a/.ccc/ccc-surface.json b/.ccc/ccc-surface.json
index 0d3154b..487d471 100644
--- a/.ccc/ccc-surface.json
+++ b/.ccc/ccc-surface.json
@@ -1,6 +1,6 @@
{
"schema": "ccc-surface/1",
- "name": "codecache",
+ "name": "code-change-capture",
"generated": "20260921-21-51-30",
"languages": ["rust", "typescript"],
"provides": [],
diff --git a/.gitignore b/.gitignore
index dc63177..89d64c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
/target
*.bin
/example/
+/examples
bench/
/dist
diff --git a/AGENTS.md b/AGENTS.md
index e077424..c876b9b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -22,5 +22,6 @@ to pick the new one up.
- a symbol: take the handle a `find` / `references` answer ends with and pass it to `edit_rename`,
`edit_replace`, `edit_delete` or `edit_insert`
- anything else (lines inside a function, docs, config, a new or removed file): `edit_text`
+ - a moved or renamed file or directory: `edit_move`, never `mv`
- read the staged diff, then `edit_apply`; `edit_discard` drops a changeset, `edit_revert` undoes an applied one
- `edit_apply` rescans the map before it answers; call `refresh` only after a change made outside ccc.
diff --git a/README.md b/README.md
index 8b072be..336f29b 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ Because the static analyser provides an overwhelming amount of data; I've added
- **Controls** - All replays are encrypted by default for repo owners [runccc teams](https://teams.runccc.dev)
- **Info Sec** - Search can be restricted to allow/block list
- **Info Sec** - Bash/terminal use is disabled
-- **Audit** - Violations are tracked by ccc and reported on for the engineer
+- **Audit** - Violations reported for human review
## Install
@@ -46,8 +46,14 @@ Run ccc from a terminal or a pipeline. Serve the map to your agents, see what a
**[CLI.md](docs/CLI.md)** has one section per command: the command, its options, and what it is for.
-### 2. Advanced
+### 2. Developers And Agents
Watch and review the work. Explore the project in the architecture visualiser, get hints in VS Code, and replay how an agent made a change.
-**[Advanced.md](docs/Advanced.md)** has one section each for the visualiser, IDEs and replays.
+**[Developers And Agents](docs/Advanced.md)** has one section each for the visualiser, IDEs and replays.
+
+### 3. Runtime Instrumentation
+
+Record one run of your test suites with ccc's probes in, and ccc knows which tests actually ran which functions, across every service and language they passed through. For any change, it picks the tests that ran the changed code. In the [examples](examples), changes that would otherwise run the whole 50-test suite need between 1 and 5 tests.
+
+**[Instrumentation.md](docs/Instrumentation.md)** covers what the examples prove, how tests and services are instrumented, and how to record in CI.
diff --git a/docs/Advanced.md b/docs/Advanced.md
index 11c2da6..fe35145 100644
--- a/docs/Advanced.md
+++ b/docs/Advanced.md
@@ -1,7 +1,7 @@
-# Advanced
+# Developers And Agents
Ways to see and review what changed, beyond the command line:
@@ -23,7 +23,7 @@ ccc run --vis
| Level | Boxes | Arrows |
|---|---|---|
-| Context | the system; peer repositories from `externals` in `.ccc/map.json`; endpoints nothing in the map answers | `ccc:calls` / `ccc:serves` crossings, by transport |
+| Context | the system; peer repositories from `externals` in `.ccc/map.json`; endpoints nothing in the map answers | cross-calls, by transport |
| Containers | services from `.ccc/map.json`, or top-level directories without one | resolved and declared calls between services |
| Components | the files in one container; other containers it touches sit outside the boundary | file-to-file calls |
| Code | the functions in one file, each with an input pin and one output pin per function it calls | resolved calls; functions in other files as stand-ins |
@@ -103,7 +103,6 @@ To build only the extension: `cd extensions/vscode && npm install && npm run pac
| `▲ 37 callers` | A hot path: among the most called, most complex or widest fan-out. |
| `↻ cycle of 3` | The function is in a call cycle. |
| `↑ calls billing in acme/billing` | The call leaves the repository. Opens the handler when that peer is checked out locally. |
-| `? billing.v1.Charge unanswered` | A `ccc:calls` whose key nothing serves: a typo at one end, or a peer missing from `externals`. |
Test, coverage and service-boundary hints are based on the diff. A file identical to the base ref has none, by design. Hot paths and cycles come from the call graph alone, so they show on any file. Every function also shows its complexity as a circled number from 1 to 10 between its name and signature, coloured from grey to red as it climbs.
@@ -182,7 +181,7 @@ This repo has a Code Change Capture (ccc) map, served over MCP. Use it as the en
- Every interaction: use `ccc` tool calls to gather information about the source of this project.
- All thinking, navigation, and questions about the codebase go through the MCP server tools: (index, find, references, dependencies, vulnerabilities, deps, security, file, notes, changes, prompts, pr_summary, test_triggers, test_targets, lints, hot, services, search, violations, refresh)
- The web, internal docs and portals included, goes through `search`: `query` to search the configured endpoints, `url` to fetch one https page. Never WebFetch, WebSearch, another MCP server's fetch or browser tool, or `curl`/`wget` - ccc can't track those, and `violations` reports each one. If `search` refuses a URL, leave it unreached.
-- ccc is the only writer: make every change through its edit tools - `edit_rename`, `edit_replace`, `edit_delete`, `edit_insert` or `edit_text`, then `edit_apply` - never your own Edit/Write tools, `sed -i` or shell redirection. `edit_discard` drops a staged changeset and `edit_revert` undoes an applied one.
+- ccc is the only writer: make every change through its edit tools - `edit_rename`, `edit_replace`, `edit_delete`, `edit_insert`, `edit_text` or `edit_move`, then `edit_apply` - never your own Edit/Write tools, `sed -i`, `mv` or shell redirection. `edit_discard` drops a staged changeset and `edit_revert` undoes an applied one.
- `edit_apply` rescans the map before it answers; call `refresh` only after a change made outside ccc.
```
diff --git a/docs/CLI.md b/docs/CLI.md
index c2170fa..fb23b75 100644
--- a/docs/CLI.md
+++ b/docs/CLI.md
@@ -11,7 +11,7 @@ claude mcp add --transport http ccc http://127.0.0.1:6767/mcp # Claude Code
copilot mcp add --transport http ccc http://127.0.0.1:6767/mcp # Copilot
```
-Then tell your agent to use the `ccc` MCP tools. [Advanced.md](Advanced.md#agents) has an `AGENTS.md` block that does this.
+Then tell your agent to use the `ccc` MCP tools. [Developers And Agents](Advanced.md#agents) has an `AGENTS.md` block that does this.
## Commands
@@ -22,6 +22,7 @@ Then tell your agent to use the `ccc` MCP tools. [Advanced.md](Advanced.md#agent
| [`ccc run`](#ccc-run) | Serving the map to agents (MCP) and people (the visualiser) |
| [`ccc changes`](#ccc-changes) | What a branch changed, function by function, and which services it puts at risk |
| [`ccc insights`](#ccc-insights) | The full analysis as JSON, including which tests to run for the branch |
+| [`ccc probe`](#ccc-probe) | A copy of the project that records which tests ran which functions |
| [`ccc deps`](#ccc-deps) | What a branch did to the dependency tree |
| [`ccc audit`](#ccc-audit) | Known vulnerabilities in your locked dependencies |
| [`ccc sast`](#ccc-sast) | Security risks in your own source |
@@ -183,24 +184,59 @@ Each line of a record file is one JSON object, `ccc-observed/1`: one fixture, as
"run": "nightly-4812",
"service": {"name": "billing", "commit": "3f2a9c1e"},
"duration_ms": 1840,
- "files": [{"path": "internal/billing/fee.go", "functions": [{"name": "(*Server).ApplyFee", "line": 88}]}]}
+ "files": [{"path": "internal/billing/fee.go", "functions": [{"name": "(*Server).ApplyFee", "line": 88, "calls": 3}]}]}
```
-- `fixture.name` is what the test runner selects it by. `kind` is `unit`, `contract`, `interop`, `integration` or `smoke`.
+- `fixture.name` is what the test runner selects it by. `kind` is `unit`, `contract`, `interop`, `integration`, `smoke` or `load`.
- `files[].path` is relative to the repository root. An absolute path is matched by its tail, so a CI checkout's paths work. A relative path the map doesn't hold belongs to another repository, and is counted as unmatched.
- A function is matched by name, then by owner, then by the nearest line, so a recording keeps matching after the code moves. A probe that sees lines rather than functions can send `"lines": [[start, end], ...]` instead. Those are used only while the file is unchanged since `service.commit`.
+- `calls` is how many times the fixture ran the function in that process. It is optional. A function listed without it still counts as run, just without a count. The counts from every service in one `run` are added together. When a fixture appears in several runs, ccc keeps its highest total, so a rerun doesn't double it.
With records:
- A function a fixture ran counts as tested. The fixture is listed first among the tests that cover it, with evidence `observed`.
- `test_triggers.observed` lists the fixtures that ran the branch's changed functions, two ways, each with its own run commands:
- `safe`, the default: every fixture that ran a changed function.
- - `minimal`: the cheapest fixtures that still run each changed function once. A fixture costs its recorded duration, weighted by kind: unit 1, contract 2, interop 3, integration 4, smoke 5. The set is the cheapest possible unless `exact` is `false`.
+ - `minimal`: the cheapest fixtures that still run each changed function once. A fixture costs its recorded duration, weighted by kind: unit 1, contract 2, interop 3, integration 4, smoke 5, load 6. The set is the cheapest possible unless `exact` is `false`.
+- Each chosen fixture lists the changed functions it ran under `ran`, with the `calls` the records gave each one. A tested-by row with evidence `observed` carries `calls` too. The `test_triggers` tool prints them as `Checksum ×2`, and the VS Code hover says how many times the run saw each test execute the function. The counts don't change which fixtures are chosen.
- `test_triggers.observed.unobserved` lists the changed functions no fixture ran. The static `run` list is all there is for those.
- The static `run` list stays as it was. A test written since the recording exists only in the source.
Agents get the same from the `test_triggers` tool. `select: "minimal"` asks for the cheaper set.
+[`ccc probe`](#ccc-probe) produces records. What they change on the example projects is in [Instrumentation.md](Instrumentation.md).
+
+## ccc probe
+
+Writes a copy of the project with ccc's runtime probes in. One run of your suites in the copy records which tests executed which functions, across every service and language the tests reach. Those records are what [observed coverage](#observed-coverage) reads. The project itself is never changed.
+
+```sh
+ccc probe go . --out /tmp/probed # one language
+ccc probe go,python . --out /tmp/probed # several, into one copy
+ccc probe rust services/billing --out /tmp/billing --kind 'tests/e2e/**=smoke'
+```
+
+In the copy:
+
+- Every function the map holds counts its runs, under the name and owner the map gives it.
+- Every test names itself a fixture: Go `TestXxx`, Rust `#[test]` and its kin, pytest tests and unittest methods, `it` and `test` callbacks, and gtest, Catch2, doctest and Boost.Test macros.
+- Servers, clients and work handed to other threads carry the fixture, between processes as a W3C `baggage` header or gRPC metadata.
+- The runtime goes in `.ccc-probe/`, or in `node_modules/ccc-probe` for TypeScript, and the build files that need to find it are edited.
+
+Probes go in on the line they belong to, so the copy keeps every line number. Python is the exception, for a function that opens with a block statement. [Instrumentation.md](Instrumentation.md#instrumenting-a-project) lists what each language covers.
+
+The probes record nothing until `CCC_OBSERVED_DIR` names a directory. `CCC_SERVICE` names the process in its records, `CCC_RUN` names the run, and `CCC_COMMIT` overrides the commit the copy was made from.
+
+| Option | Default | What it does |
+|---|---|---|
+| `LANG` | required | `go`, `rust`, `cpp` (or `c++`), `typescript` (or `ts`) or `python` (or `py`). Comma-separate several to probe them into one copy. |
+| `PATH` | `.` | The project, or the part of it, to instrument. |
+| `--out DIR` | required | Where the copy goes. |
+| `--kind GLOB=KIND` | from the path | The fixture kind of the tests a glob matches: `unit`, `contract`, `interop`, `integration`, `smoke` or `load`. Repeatable, and the first match wins. Without one, the kind comes from the test file's name, then its directories, innermost first. |
+| `--root DIR` | the nearest `.ccc` or `.git` above `PATH` | The directory record paths are relative to. Records match the map only when this is the directory ccc runs in. |
+| `--force` | off | Write into an `--out` that already holds files. |
+| `--format FMT` | `text` | `json` prints the report as one object. |
+
## ccc deps
Reports what the branch did to the dependency tree: which packages entered, left or changed version, and any advisory that came with them.
@@ -315,7 +351,7 @@ Copilot's chat log keeps no record of tool calls, so only Claude Code sessions a
## ccc replay
-Manages replays: the story of what agents did on a branch, kept under `refs/ccc/replay/`. They sit beside your branches, never in them, so a merge can't carry one into the default branch. Recording is opt-in per repository. [Advanced.md](Advanced.md#replays) covers how replays are kept private.
+Manages replays: the story of what agents did on a branch, kept under `refs/ccc/replay/`. They sit beside your branches, never in them, so a merge can't carry one into the default branch. Recording is opt-in per repository. [Developers And Agents](Advanced.md#replays) covers how replays are kept private.
### ccc replay enable
diff --git a/docs/Instrumentation.md b/docs/Instrumentation.md
new file mode 100644
index 0000000..6a4d582
--- /dev/null
+++ b/docs/Instrumentation.md
@@ -0,0 +1,197 @@
+# Runtime Instrumentation
+
+ccc's map is static: it links a test to the code the test calls. A test that reaches code another way is invisible to it, for example over HTTP to another service, through a framework, or through a table of handlers. A change in one service then leaves you running every suite that might touch it.
+
+Runtime instrumentation fills that gap. Record one run of your suites with ccc's probes in, and ccc knows which tests actually executed which functions, across every service and language the tests passed through. For any change, it picks the tests that ran the changed code.
+
+- [What the examples prove](#what-the-examples-prove)
+- [How it works](#how-it-works)
+- [Instrumenting a project](#instrumenting-a-project)
+- [Recording in CI](#recording-in-ci)
+- [What is built and what isn't yet](#what-is-built-and-what-isnt-yet)
+
+---
+
+## What the examples prove
+
+[`examples/`](../examples) holds two copies of the same blob-store SaaS (disclosure: all examples are generated by Agents): five Go services (gateway, auth, metadata, storage and billing) behind one public gateway, and 50 tests.
+
+| Suite | Tests | How it reaches the services |
+|---|---|---|
+| Go unit | 20 | In process |
+| Go integration | 6 | Over HTTP, through test servers |
+| Python integration: customer flows | 12 | Over HTTP |
+| Python contract: response shapes | 8 | Over HTTP |
+| Python load: throughput and latency | 4 | Over HTTP |
+
+- **Project-A** is the plain project. All ccc has is its static map.
+- **Project-BBB** is the same source, byte for byte, plus ccc's probes and one recorded run of all 50 tests.
+
+### The result
+
+The same three changes went into each project. The numbers are how many of the 50 tests each project has to run.
+
+| Scenario | Change | Changed functions | Without instrumentation (Project-A) | With: safe (Project-BBB) | With: minimal (Project-BBB) |
+|---|---|---|---|---|---|
+| 1 | metadata counts list and delete calls for a metrics endpoint | 2 | 50 | 11 | **2** |
+| 2 | storage hashes chunks through a streaming hasher | 1 | 50 | 22 | **1** |
+| 3 | a hardening release across all five services | 6 | 50 | 42 | **5** |
+
+- **safe:** every test the recorded run saw execute a changed function.
+- **minimal:** the cheapest tests that still execute every changed function at least once.
+
+### Scenario by scenario
+
+**1. A simple change that still needs more than one test.** `Index.Delete` and `Index.List` sit on different request paths.
+- Without instrumentation, ccc's call graph links no test to either function. The blast radius covers all eight services, so every test runs.
+- The recording shows 11 tests executing one of the two: 2 unit, 6 integration, 1 contract and 2 load.
+- `minimal` needs two unit tests, one per path:
+
+```sh
+go test -run '^(TestDeleteObject|TestListObjectsSortedWithPrefix)$' ./services/metadata
+```
+
+**2. A one-function change deep in the stack.** `Checksum` runs on every upload and download.
+- The call graph finds the 4 storage unit tests that call it directly. It can't see the 18 tests that also run it over HTTP: 10 integration, 4 contract and 4 load.
+- The recording sees all 22.
+- `minimal` needs one test:
+
+```sh
+go test -run '^(TestPutGetChunk)$' ./services/storage
+```
+
+**3. A complex change across every service.** It changes six functions in five services.
+- The call graph links 2 tests, while 42 tests actually ran at least one changed function.
+- `minimal` needs five, one per service:
+
+```sh
+go test -run '^(TestVerifyKnownKey|TestRecordAccumulatesUsage|TestJoinChunksReversesSplit|TestCreateBucketRejectsBadNames|TestPutGetChunk)$' \
+ ./services/auth ./services/billing ./services/gateway ./services/metadata ./services/storage
+```
+
+### What this shows
+
+- **Without runtime data, the suites that cross services can't be narrowed.** Every scenario puts all eight services in ccc's blast radius, so every scenario runs the whole suite.
+- **The call graph's own short list isn't safe to run alone.** It finds 0, 4 and 2 tests for the three scenarios, while 11, 22 and 42 tests actually ran the changed code.
+- **One recorded run is enough to narrow it.** The safe set runs 78%, 56% and 16% fewer tests than the whole suite, and the minimal set 96%, 98% and 90% fewer.
+- **The services' source doesn't change.** The probes go into a build-time copy, and with recording off the tests behave exactly as they do in Project-A.
+
+`minimal` promises that the changed code runs, not that every regression is caught. It prefers cheap tests: cost is the recorded duration weighted by kind, from unit 1 up to load 6. When a change matters enough, run `safe`. In these scenarios `minimal` never needs a load test, while `safe` keeps every load test that ran the change.
+
+### How often each test ran the change
+
+The probes also count how many times each test ran each function. ccc prints the count beside every test it selects, as in `Checksum ×2`, and the VS Code hover shows it too. The counts don't change which tests are selected. They show how hard each test works the changed code:
+
+| Scenario | `minimal` | The busiest test in `safe` |
+|---|---|---|
+| 1 | `TestDeleteObject`: Delete ×1 `TestListObjectsSortedWithPrefix`: List ×2 | `test_list_under_load`: List ×101 |
+| 2 | `TestPutGetChunk`: Checksum ×2 | `test_upload_throughput`: Checksum ×600 |
+| 3 | Five unit tests, each running its function 1 to 6 times | `test_download_latency_p95`: Verify ×202, PriceCents ×201, joinChunks ×200, Get ×200, validBucketName ×1 |
+
+A `minimal` test runs the change a handful of times. A regression that only appears under repetition, such as a slowdown, a leak or a race, is far more likely to show in a test that runs the code hundreds of times.
+
+To see it yourself, run `bash examples/run-scenarios.sh`. It needs only ccc, git and Python 3.
+
+---
+
+## How it works
+
+1. **Each test is a fixture.** A fixture has the name the test runner selects it by, a kind (`unit`, `contract`, `interop`, `integration`, `smoke` or `load`), its file and its language.
+2. **Its identity travels with its requests.** Every request a fixture makes carries a W3C `baggage` header, `ccc.fixture=…,ccc.file=…,ccc.lang=…,ccc.kind=…`, and each service passes it on to the services it calls.
+3. **Services record what ran for it.** Each function counts, on entry, one run for the fixture behind the current request.
+4. **The records are `ccc-observed/1` files.** Each line covers one fixture in one process: the fixture, how long it took, and the functions it ran, by file, name and line, with how many times each ran. [CLI.md](CLI.md#observed-coverage) has the format.
+5. **ccc reads them** from `observed` in `.ccc/map.json`, from `--observed`, or from `.ccc/observed/`. It ties each recorded function to its own map by file, name and owner, so a recording still matches after the code has moved.
+6. **ccc selects.** In `ccc insights` and the `test_triggers` MCP tool, `test_triggers.observed` lists the safe and minimal sets, each with its run commands. `ccc changes` and the VS Code hints count a function a fixture ran as tested, with evidence `observed`. Each of these shows how many times the fixture ran the function.
+
+## Instrumenting a project
+
+`ccc probe` writes an instrumented copy of the project. The source in the repository never changes.
+
+```sh
+ccc probe go,python . --out /tmp/probed
+cd /tmp/probed
+export CCC_OBSERVED_DIR=$PWD/observed
+go test ./... && pytest
+```
+
+[CLI.md](CLI.md#ccc-probe) has every option. In the Go copy, for example:
+
+```diff
+-package storage
++package storage; import cccprobe "ccc.invalid/probe"
+-func (s *Store) Put(id string, data []byte, checksum string) error {
++func (s *Store) Put(id string, data []byte, checksum string) error { cccprobe.Hit("services/storage/storage.go", "Store.Put", 39);
+-func TestPutGetChunk(t *testing.T) {
++func TestPutGetChunk(t *testing.T) { cccprobe.Test(t, "services/storage/storage_test.go", "unit");
+- srv := &http.Server{Handler: h, ReadHeaderTimeout: 5 * time.Second}
++ srv := &http.Server{Handler: cccprobe.Handler(h), ReadHeaderTimeout: 5 * time.Second}
+```
+
+The probes are inserted as text, on the line they belong to, so every comment stays where it was and every line keeps its number. Each function is named the way ccc's own map names it, so the records match the map exactly.
+
+What goes into the copy, by language:
+
+| Language | Counts its runs | Names itself a fixture | Takes the fixture in | Sends the fixture out | Hands it to other threads |
+|---|---|---|---|---|---|
+| Go | every function and method except `init` | `TestXxx(t *testing.T)`, with its subtests | `net/http` servers (`http.Server`, `ListenAndServe`, `Serve`, `httptest`), gRPC servers | `http.DefaultTransport`, `http.Client` transports, gRPC dials | goroutines, through the goroutine that started them (Go 1.21+) |
+| Rust | every function except `const fn` | `#[test]`, `#[tokio::test]`, `#[rstest]`, `#[test_case]` and the like | axum 0.7+ and tonic 0.12+ through a tower layer, actix-web through `wrap_fn` | reqwest `.send()`, `tonic::Request::new` | `tokio::spawn`, `spawn_blocking`, `std::thread::spawn` |
+| C++ | every function except `constexpr` | gtest `TEST`, `TEST_F`, `TEST_P`, Catch2 and doctest `TEST_CASE`, `SCENARIO`, Boost.Test | cpp-httplib routes, grpc++ servers | cpp-httplib clients, `grpc::CreateChannel` | `std::thread`, `std::jthread`, `std::async` |
+| TypeScript | every function, method and arrow bound to a name | `it`, `test` and `specify` callbacks, under jest, vitest, mocha and `node --test` | Node `http` and `https` servers (express, fastify, koa and the rest), grpc-js servers | `http` and `https` requests (axios, got, node-fetch), `fetch`, grpc-js clients | every async continuation, through `AsyncLocalStorage` |
+| Python | every function and method | pytest tests, unittest methods | WSGI (Flask, Django, wsgiref), ASGI (Starlette, FastAPI, Django), `http.server`, grpcio | `http.client` (urllib, requests, urllib3), httpx, aiohttp, grpcio | threads and `ThreadPoolExecutor` |
+
+How each runtime reaches the build:
+
+- **Go:** a module, `ccc.invalid/probe`, in `.ccc-probe/go`. Each probed `go.mod` requires it and replaces it with that directory, and so do `go.work` and `vendor/` where there are any. A module that requires gRPC also gets `internal/cccprobegrpc`.
+- **Rust:** a crate, `ccc_probe`, in `.ccc-probe/rust`, added to each probed package as a path dependency, with its `tower` feature on where axum or tonic is used. It needs Rust 1.70. A `--locked` build needs its lock file updated first.
+- **C++:** a header in `.ccc-probe/cpp`, included by relative path with a `#line` after it. No build file changes. It needs C++17.
+- **TypeScript:** a package in the copy's `node_modules/ccc-probe`, imported by every probed file. The copy's `node_modules` links each installed package back to the original.
+- **Python:** a module in `.ccc-probe/python` that every probed file finds by itself. It registers with pytest as the test files load, so nothing needs installing.
+
+A test's kind comes from its file name, then its directories, innermost first: `contract`, `interop`, `integration` (or `e2e`, `it`, `acceptance`, `functional`), `smoke`, and `load` (or `perf`, `stress`, `bench`). A Rust crate's `tests/` directory is `integration`. Everything else is `unit`. `--kind 'qa/**=smoke'` overrides it.
+
+Every probe stays off unless `CCC_OBSERVED_DIR` names a directory, so the copy runs unchanged otherwise. `CCC_SERVICE` names the process in its records, `CCC_RUN` names the run (`local` by default), and `CCC_COMMIT` overrides the commit the copy was made from.
+
+A test's record is written when the test ends. A service writes what its fixtures ran every two seconds, and again when it exits.
+
+**Limits:**
+- Only functions the map names are counted. Function literals and anonymous lambdas aren't.
+- Work that no test, request or parent claims goes to the process's one running test, and to nobody when two or more run at once. Run tests serially, for example with `--test-threads=1`, to attribute everything.
+- A Rust async test on a multi-threaded runtime is followed only when the tests run serially.
+- A grpc++ server binds the fixture on the thread that receives each call, so synchronous services are followed and callback services aren't. Its interceptor replaces any set on the builder before it.
+- In a project that also has `.c` files, `.h` files stay unprobed, because a C source might include them.
+- A Python function that opens with a block statement gets its count on a line of its own, so the lines after it shift in the copy. The records still carry each function's original line.
+- JavaScript files aren't probed, only TypeScript.
+
+## Recording in CI
+
+On the default branch, nightly or on every merge:
+
+1. `ccc probe . --out $WORK` makes the instrumented copy.
+2. Build and run every suite in `$WORK` with `CCC_OBSERVED_DIR` set. Start each service the suites call from the copy too, with `CCC_SERVICE` naming it.
+3. Stop the services cleanly, or give them two seconds to write what they ran.
+4. `cat $CCC_OBSERVED_DIR/*.jsonl > observed.jsonl`, and publish the file as a build artifact.
+
+Then point ccc at it:
+
+```json
+"observed": [{ "from": "https://ci.example.com/artifacts/observed.jsonl", "auth": "env:CI_TOKEN" }]
+```
+
+The example's [`record.sh`](../examples/project-bbb/ccc/record.sh) predates `ccc probe` and uses the hand-written probes in [`examples/project-bbb/ccc`](../examples/project-bbb/ccc). `ccc probe go,python examples/project-a` produces the same 50 records from the plain project, function for function and call for call.
+
+ccc fetches a URL at most once every ten minutes. Records are matched by function name, so they stay useful as the code moves. A function written since the recording has no record yet. It's listed under `unobserved`, and the static call-graph list covers it until the next recording.
+
+## Functionality
+
+**Available Now**
+- The `ccc-observed/1` format and the reader.
+- Observed evidence in `ccc changes`, `test_triggers` and the VS Code hints.
+- The safe and minimal selections, with run commands.
+- How many times each test ran each changed function.
+- `ccc probe`, for Go, Rust, C++, TypeScript and Python, carrying the fixture over HTTP and gRPC.
+
+**Should be available in 2.0.0**
+- JavaScript files.
+- Carrying the fixture through message queues.
+- Line-level precision for `safe`.
+- Drift reports that say when a test stops reaching what it used to.
diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md
index a5690e3..c6c8226 100644
--- a/extensions/vscode/README.md
+++ b/extensions/vscode/README.md
@@ -66,7 +66,7 @@ For hints about services and other repositories, describe your services in `.ccc
- `ccc.binaryPath` and `ccc.autoInstall`: use your own `ccc`, or stop the extension downloading one.
- `ccc.server.*`: how the analyser runs. `ccc.server.extraArgs` passes extra `ccc run` options.
-How the hints are worked out, every setting in detail, and troubleshooting are in the [Advanced guide](../../docs/Advanced.md#ides).
+How the hints are worked out, every setting in detail, and troubleshooting are in the [Developers And Agents guide](../../docs/Advanced.md#ides).
## License
diff --git a/extensions/vscode/src/hover.ts b/extensions/vscode/src/hover.ts
index 8da03e7..45e6d25 100644
--- a/extensions/vscode/src/hover.ts
+++ b/extensions/vscode/src/hover.ts
@@ -151,7 +151,7 @@ function testList(tests: TestLink[]): string[] {
? ' — direct'
: ` — ${test.distance} call ${test.distance === 1 ? 'hop' : 'hops'} away`;
out.push(
- `- ${locationLink(test.name, test.file, test.line, true)} \`${where}\`${hops}${evidenceNote(test.evidence)}`,
+ `- ${locationLink(test.name, test.file, test.line, true)} \`${where}\`${hops}${evidenceNote(test.evidence, test.calls)}`,
);
} else {
out.push(`- ${code(test.name)} — _not in the trigger set, so no location was recorded_`);
@@ -161,8 +161,11 @@ function testList(tests: TestLink[]): string[] {
}
// the weak tie and the run-observed one are worth a word - the rest are what "covered by" already implies
-function evidenceNote(evidence: string | undefined): string {
- if (evidence === 'observed') return ' _(a recorded run saw it execute this)_';
+function evidenceNote(evidence: string | undefined, calls?: number): string {
+ if (evidence === 'observed') {
+ if (calls === undefined) return ' _(a recorded run saw it execute this)_';
+ return ` _(a recorded run saw it execute this ${calls === 1 ? 'once' : `${calls} times`})_`;
+ }
return evidence === 'name-only' ? ' _(matched on the name alone)_' : '';
}
diff --git a/extensions/vscode/src/model.ts b/extensions/vscode/src/model.ts
index 376187f..9d8547f 100644
--- a/extensions/vscode/src/model.ts
+++ b/extensions/vscode/src/model.ts
@@ -71,6 +71,8 @@ export interface TestLink {
confidence: TestConfidence;
// how the analyser tied this test to the function - absent on legacy payloads
evidence?: string;
+ // how many times a recorded run saw the test run the function
+ calls?: number;
}
export type CoverageStatus = 'tested' | 'untested' | 'test-code';
@@ -665,6 +667,7 @@ function linkTests(
reason: run ? str(run.reason) || undefined : undefined,
confidence: 'exact',
evidence: str(site.evidence) || undefined,
+ calls: num(site.calls, -1) >= 0 ? num(site.calls) : undefined,
};
});
links.sort((a, b) => (a.distance ?? 99) - (b.distance ?? 99) || a.name.localeCompare(b.name));
diff --git a/extensions/vscode/src/types.ts b/extensions/vscode/src/types.ts
index 7c8b278..efe7804 100644
--- a/extensions/vscode/src/types.ts
+++ b/extensions/vscode/src/types.ts
@@ -82,6 +82,8 @@ export interface TestedBySite {
language: string;
// observed | receiver-type | same-file | same-package | import | qualifier | rpc | name-only
evidence: string;
+ // how many times a recorded run saw the test run the function - observed rows from a probe that counts
+ calls?: number;
}
export interface ChangedFile {
diff --git a/probes/cpp/ccc_probe.hpp b/probes/cpp/ccc_probe.hpp
new file mode 100644
index 0000000..8944148
--- /dev/null
+++ b/probes/cpp/ccc_probe.hpp
@@ -0,0 +1,382 @@
+// ccc's c++ probe - records which functions ran on behalf of which test fixture and how many times, as ccc-observed/1 lines
+// off unless CCC_OBSERVED_DIR names a directory - `ccc probe cpp` includes this header into a copy of the project, c++17 or later
+#ifndef CCC_PROBE_HPP
+#define CCC_PROBE_HPP
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#if defined(_WIN32)
+#include
+#else
+#include
+#endif
+
+namespace ccc_probe {
+
+// one test, named the way its runner selects it
+struct Fixture {
+ std::string name, file, language, kind;
+};
+using FixtureRef = std::shared_ptr;
+
+namespace detail {
+
+// the commit the copy was made from - CCC_COMMIT overrides it
+inline const char* commit() { return ""; }
+
+// how often a service writes what its fixtures ran - a process killed rather than stopped loses at most this much
+constexpr int flush_seconds = 2;
+
+struct Site {
+ const char* file;
+ const char* name;
+ int line;
+ bool operator==(const Site& o) const { return file == o.file && name == o.name && line == o.line; }
+};
+
+struct SiteHash {
+ std::size_t operator()(const Site& s) const {
+ return std::hash()(s.file) ^ (std::hash()(s.name) << 1) ^ (std::hash()(s.line) << 2);
+ }
+};
+
+struct Entry {
+ FixtureRef fixture;
+ std::unordered_map sites;
+};
+
+// never destroyed, so a detached flusher or a static destructor can still reach it as the process ends
+struct State {
+ std::mutex mu;
+ std::mutex writing;
+ // what each fixture ran that is not written yet
+ std::map ran;
+ // this process's own tests still running - each writes its record when it ends
+ std::map> local;
+ bool ticking = false;
+};
+
+inline State& state() {
+ static State* s = new State();
+ return *s;
+}
+
+inline const char* dir() {
+ static const char* d = [] {
+ const char* v = std::getenv("CCC_OBSERVED_DIR");
+ return (v && *v) ? v : nullptr;
+ }();
+ return d;
+}
+
+inline FixtureRef& bound() {
+ thread_local FixtureRef f;
+ return f;
+}
+
+inline std::string key(const Fixture& f) { return f.name + '\0' + f.file + '\0' + f.language + '\0' + f.kind; }
+
+inline std::string json(const std::string& s) {
+ std::string out = "\"";
+ for (unsigned char c : s) {
+ if (c == '"' || c == '\\') {
+ out += '\\';
+ out += static_cast(c);
+ } else if (c < 0x20) {
+ char buf[8];
+ std::snprintf(buf, sizeof buf, "\\u%04x", c);
+ out += buf;
+ } else {
+ out += static_cast(c);
+ }
+ }
+ return out + "\"";
+}
+
+inline std::string encode(const std::string& s) {
+ static const char* hex = "0123456789ABCDEF";
+ std::string out;
+ for (unsigned char c : s) {
+ if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') {
+ out += static_cast(c);
+ } else {
+ out += '%';
+ out += hex[c >> 4];
+ out += hex[c & 15];
+ }
+ }
+ return out;
+}
+
+inline std::string decode(const std::string& s) {
+ std::string out;
+ for (std::size_t i = 0; i < s.size(); ++i) {
+ if (s[i] == '+') {
+ out += ' ';
+ } else if (s[i] == '%' && i + 2 < s.size() && std::isxdigit(static_cast(s[i + 1])) && std::isxdigit(static_cast(s[i + 2]))) {
+ out += static_cast(std::stoi(s.substr(i + 1, 2), nullptr, 16));
+ i += 2;
+ } else {
+ out += s[i];
+ }
+ }
+ return out;
+}
+
+inline std::string env(const char* name, const std::string& fallback) {
+ const char* v = std::getenv(name);
+ return (v && *v) ? std::string(v) : fallback;
+}
+
+inline std::string service() {
+ std::string name = env("CCC_SERVICE", "");
+ if (!name.empty()) return name;
+#if defined(__linux__)
+ if (std::FILE* f = std::fopen("/proc/self/comm", "r")) {
+ char buf[256] = {0};
+ if (std::fgets(buf, sizeof buf, f)) name = buf;
+ std::fclose(f);
+ while (!name.empty() && (name.back() == '\n' || name.back() == '\r')) name.pop_back();
+ }
+#endif
+ return name.empty() ? "cpp" : name;
+}
+
+inline long pid() {
+#if defined(_WIN32)
+ return static_cast(_getpid());
+#else
+ return static_cast(getpid());
+#endif
+}
+
+// one ccc-observed/1 line - a fixture, and the functions it ran in this process with how many times each ran
+inline void write(const Fixture& f, const std::unordered_map& sites, long long ms) {
+ if (sites.empty() && ms < 0) return;
+ // the same literal can sit at two addresses in two translation units, so sites merge by what they say
+ std::map, std::uint64_t>> files;
+ for (const auto& [site, calls] : sites) files[site.file][{site.line, site.name}] += calls;
+ std::string out = "{\"schema\":\"ccc-observed/1\",\"fixture\":{\"name\":" + json(f.name) + ",\"kind\":" + json(f.kind) + ",\"file\":" + json(f.file) +
+ ",\"language\":" + json(f.language) + "},\"run\":" + json(env("CCC_RUN", "local")) + ",\"service\":{\"name\":" + json(service());
+ std::string at = env("CCC_COMMIT", commit());
+ if (!at.empty()) out += ",\"commit\":" + json(at);
+ out += "},\"files\":[";
+ bool first_file = true;
+ for (const auto& [path, fns] : files) {
+ out += first_file ? "" : ",";
+ first_file = false;
+ out += "{\"path\":" + json(path) + ",\"functions\":[";
+ bool first = true;
+ for (const auto& [where, calls] : fns) {
+ out += first ? "" : ",";
+ first = false;
+ out += "{\"name\":" + json(where.second) + ",\"line\":" + std::to_string(where.first) + ",\"calls\":" + std::to_string(calls) + "}";
+ }
+ out += "]}";
+ }
+ out += "]";
+ if (ms >= 0) out += ",\"duration_ms\":" + std::to_string(ms);
+ out += "}\n";
+ std::lock_guard lock(state().writing);
+ std::error_code ignored;
+ std::filesystem::create_directories(dir(), ignored);
+ std::string path = std::string(dir()) + "/" + service() + "-" + std::to_string(pid()) + ".jsonl";
+ if (std::FILE* file = std::fopen(path.c_str(), "a")) {
+ std::fwrite(out.data(), 1, out.size(), file);
+ std::fclose(file);
+ }
+}
+
+} // namespace detail
+
+// writes what fixtures ran in this process that no test of its own is still running
+inline void flush() {
+ if (!detail::dir()) return;
+ std::vector pending;
+ {
+ auto& s = detail::state();
+ std::lock_guard lock(s.mu);
+ for (auto it = s.ran.begin(); it != s.ran.end();) {
+ if (s.local.count(it->first)) {
+ ++it;
+ continue;
+ }
+ pending.push_back(std::move(it->second));
+ it = s.ran.erase(it);
+ }
+ }
+ for (const auto& e : pending) detail::write(*e.fixture, e.sites, -1);
+}
+
+namespace detail {
+
+inline void tick() {
+ auto& s = state();
+ {
+ std::lock_guard lock(s.mu);
+ if (s.ticking) return;
+ s.ticking = true;
+ }
+ std::thread([] {
+ for (;;) {
+ std::this_thread::sleep_for(std::chrono::seconds(flush_seconds));
+ flush();
+ }
+ }).detach();
+}
+
+// what fixtures ran is written when the process exits normally
+struct AtExit {
+ ~AtExit() { flush(); }
+};
+inline AtExit at_exit;
+
+} // namespace detail
+
+// the fixture this work runs for - bound to this thread, or this process's one running test
+inline FixtureRef current() {
+ if (!detail::dir()) return nullptr;
+ if (FixtureRef f = detail::bound()) return f;
+ auto& s = detail::state();
+ std::lock_guard lock(s.mu);
+ if (s.local.size() == 1) return s.local.begin()->second.first;
+ return nullptr;
+}
+
+// counts one run of a function for the fixture this work runs for
+inline void hit(const char* file, const char* name, int line) {
+ if (!detail::dir()) return;
+ detail::tick();
+ FixtureRef f = current();
+ if (!f) return;
+ auto& s = detail::state();
+ std::lock_guard lock(s.mu);
+ auto& e = s.ran[detail::key(*f)];
+ if (!e.fixture) e.fixture = f;
+ ++e.sites[detail::Site{file, name, line}];
+}
+
+// makes this thread work for a fixture until it goes out of scope - a null fixture leaves whatever was bound
+class Bind {
+ public:
+ explicit Bind(FixtureRef f) : active_(f != nullptr) {
+ if (active_) prev_ = std::exchange(detail::bound(), std::move(f));
+ }
+ ~Bind() {
+ if (active_) detail::bound() = std::move(prev_);
+ }
+ Bind(const Bind&) = delete;
+ Bind& operator=(const Bind&) = delete;
+
+ private:
+ bool active_;
+ FixtureRef prev_;
+};
+
+// a test as a fixture until it goes out of scope - its record is written then
+class Test {
+ public:
+ Test(const char* name, const char* file, const char* kind) {
+ if (!detail::dir()) return;
+ fixture_ = std::make_shared(Fixture{name, file, "cpp", kind});
+ {
+ auto& s = detail::state();
+ std::lock_guard lock(s.mu);
+ auto& open = s.local[detail::key(*fixture_)];
+ open.first = fixture_;
+ ++open.second;
+ }
+ bind_ = std::make_unique(fixture_);
+ start_ = std::chrono::steady_clock::now();
+ }
+ ~Test() {
+ if (!fixture_) return;
+ bind_.reset();
+ auto ms = std::chrono::duration_cast(std::chrono::steady_clock::now() - start_).count();
+ detail::Entry entry;
+ {
+ auto& s = detail::state();
+ std::lock_guard lock(s.mu);
+ std::string k = detail::key(*fixture_);
+ auto open = s.local.find(k);
+ if (open != s.local.end() && --open->second.second <= 0) s.local.erase(open);
+ auto ran = s.ran.find(k);
+ if (ran != s.ran.end()) {
+ entry = std::move(ran->second);
+ s.ran.erase(ran);
+ }
+ }
+ detail::write(*fixture_, entry.sites, ms);
+ }
+ Test(const Test&) = delete;
+ Test& operator=(const Test&) = delete;
+
+ private:
+ FixtureRef fixture_;
+ std::unique_ptr bind_;
+ std::chrono::steady_clock::time_point start_;
+};
+
+inline Test fixture(const char* name, const char* file, const char* kind) { return Test(name, file, kind); }
+
+// the fixture as w3c baggage - the same four members every ccc probe sends
+inline std::string baggage(const Fixture& f) {
+ return "ccc.fixture=" + detail::encode(f.name) + ",ccc.file=" + detail::encode(f.file) + ",ccc.lang=" + detail::encode(f.language) +
+ ",ccc.kind=" + detail::encode(f.kind);
+}
+
+// the fixture a baggage header names, among whatever other members it carries
+inline FixtureRef from_baggage(const std::string& header) {
+ Fixture f;
+ std::size_t start = 0;
+ while (start <= header.size()) {
+ std::size_t end = header.find(',', start);
+ std::string member = header.substr(start, end == std::string::npos ? std::string::npos : end - start);
+ member = member.substr(0, member.find(';'));
+ std::size_t eq = member.find('=');
+ if (eq != std::string::npos) {
+ std::string k = member.substr(0, eq), v = member.substr(eq + 1);
+ auto trim = [](std::string& t) {
+ t.erase(0, t.find_first_not_of(" \t"));
+ t.erase(t.find_last_not_of(" \t") + 1);
+ };
+ trim(k);
+ trim(v);
+ v = detail::decode(v);
+ if (k == "ccc.fixture") f.name = v;
+ if (k == "ccc.file") f.file = v;
+ if (k == "ccc.lang") f.language = v;
+ if (k == "ccc.kind") f.kind = v;
+ }
+ if (end == std::string::npos) break;
+ start = end + 1;
+ }
+ if (f.name.empty() || f.kind.empty()) return nullptr;
+ return std::make_shared(std::move(f));
+}
+
+// a callable handed to another thread carries the fixture of whoever handed it over
+template
+auto bind_current(F fn) {
+ return [f = current(), fn = std::move(fn)](auto&&... args) mutable -> decltype(auto) {
+ Bind b(f);
+ return std::invoke(fn, std::forward(args)...);
+ };
+}
+
+} // namespace ccc_probe
+
+#endif
diff --git a/probes/cpp/ccc_probe_grpc.hpp b/probes/cpp/ccc_probe_grpc.hpp
new file mode 100644
index 0000000..6d0077b
--- /dev/null
+++ b/probes/cpp/ccc_probe_grpc.hpp
@@ -0,0 +1,82 @@
+// carries ccc's fixture through grpc++ - `ccc probe cpp` includes this right after the grpcpp include in the files that use it
+// a server binds the fixture on the thread that runs each call, so synchronous services are followed and callback ones are not
+#ifndef CCC_PROBE_GRPC_HPP
+#define CCC_PROBE_GRPC_HPP
+
+#include
+#include
+#include
+
+#include "ccc_probe.hpp"
+
+namespace ccc_probe {
+namespace detail {
+
+class GrpcServerInterceptor : public grpc::experimental::Interceptor {
+ public:
+ void Intercept(grpc::experimental::InterceptorBatchMethods* methods) override {
+ using grpc::experimental::InterceptionHookPoints;
+ if (methods->QueryInterceptionHookPoint(InterceptionHookPoints::POST_RECV_INITIAL_METADATA)) {
+ if (auto* md = methods->GetRecvInitialMetadata()) {
+ auto it = md->find("baggage");
+ if (it != md->end()) bound_ = std::make_unique(from_baggage(std::string(it->second.data(), it->second.size())));
+ }
+ }
+ if (methods->QueryInterceptionHookPoint(InterceptionHookPoints::PRE_SEND_STATUS)) bound_.reset();
+ methods->Proceed();
+ }
+
+ private:
+ std::unique_ptr bound_;
+};
+
+class GrpcServerFactory : public grpc::experimental::ServerInterceptorFactoryInterface {
+ public:
+ grpc::experimental::Interceptor* CreateServerInterceptor(grpc::experimental::ServerRpcInfo*) override { return new GrpcServerInterceptor(); }
+};
+
+class GrpcClientInterceptor : public grpc::experimental::Interceptor {
+ public:
+ void Intercept(grpc::experimental::InterceptorBatchMethods* methods) override {
+ if (methods->QueryInterceptionHookPoint(grpc::experimental::InterceptionHookPoints::PRE_SEND_INITIAL_METADATA)) {
+ if (FixtureRef f = current()) methods->GetSendInitialMetadata()->insert({"baggage", baggage(*f)});
+ }
+ methods->Proceed();
+ }
+};
+
+class GrpcClientFactory : public grpc::experimental::ClientInterceptorFactoryInterface {
+ public:
+ grpc::experimental::Interceptor* CreateClientInterceptor(grpc::experimental::ClientRpcInfo*) override { return new GrpcClientInterceptor(); }
+};
+
+inline std::vector> client_factories() {
+ std::vector> out;
+ out.push_back(std::make_unique());
+ return out;
+}
+
+} // namespace detail
+
+// a server builder whose server binds each call to the fixture its metadata names - this replaces interceptors set before it
+template
+Builder& grpc_server(Builder& builder) {
+ std::vector> creators;
+ creators.push_back(std::make_unique());
+ builder.experimental().SetInterceptorCreators(std::move(creators));
+ return builder;
+}
+
+// grpc::CreateChannel, with every call carrying the caller's fixture
+inline std::shared_ptr grpc_channel(const grpc::string& target, const std::shared_ptr& creds) {
+ return grpc::experimental::CreateCustomChannelWithInterceptors(target, creds, grpc::ChannelArguments(), detail::client_factories());
+}
+
+// grpc::CreateCustomChannel, with every call carrying the caller's fixture
+inline std::shared_ptr grpc_custom_channel(const grpc::string& target, const std::shared_ptr& creds, const grpc::ChannelArguments& args) {
+ return grpc::experimental::CreateCustomChannelWithInterceptors(target, creds, args, detail::client_factories());
+}
+
+} // namespace ccc_probe
+
+#endif
diff --git a/probes/cpp/ccc_probe_httplib.hpp b/probes/cpp/ccc_probe_httplib.hpp
new file mode 100644
index 0000000..55444bc
--- /dev/null
+++ b/probes/cpp/ccc_probe_httplib.hpp
@@ -0,0 +1,39 @@
+// carries ccc's fixture through cpp-httplib - `ccc probe cpp` includes this right after httplib.h in the files that use it
+#ifndef CCC_PROBE_HTTPLIB_HPP
+#define CCC_PROBE_HTTPLIB_HPP
+
+#include "ccc_probe.hpp"
+
+namespace ccc_probe {
+
+// a server handler that serves each request for the fixture its baggage names
+inline httplib::Server::Handler httplib_serve(httplib::Server::Handler h) {
+ return [h = std::move(h)](const httplib::Request& req, httplib::Response& res) {
+ Bind b(from_baggage(req.get_header_value("baggage")));
+ h(req, res);
+ };
+}
+
+inline httplib::Server::HandlerWithContentReader httplib_serve(httplib::Server::HandlerWithContentReader h) {
+ return [h = std::move(h)](const httplib::Request& req, httplib::Response& res, const httplib::ContentReader& reader) {
+ Bind b(from_baggage(req.get_header_value("baggage")));
+ h(req, res, reader);
+ };
+}
+
+// a client whose every request carries the fixture it is sent for
+template
+Client& httplib_client(Client& client) {
+ client.set_header_writer([](httplib::Stream& strm, httplib::Headers& headers) {
+ if (FixtureRef f = current()) {
+ headers.erase("baggage");
+ headers.emplace("baggage", baggage(*f));
+ }
+ return httplib::detail::write_headers(strm, headers);
+ });
+ return client;
+}
+
+} // namespace ccc_probe
+
+#endif
diff --git a/probes/go/cccprobe.go b/probes/go/cccprobe.go
new file mode 100644
index 0000000..563d62e
--- /dev/null
+++ b/probes/go/cccprobe.go
@@ -0,0 +1,394 @@
+// ccc's go probe - records which functions ran on behalf of which test fixture and how many times, as ccc-observed/1 lines
+// off unless CCC_OBSERVED_DIR names a directory - `ccc probe go` puts the calls into a copy of the module and this package beside it
+
+package cccprobe
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+const schema = "ccc-observed/1"
+
+// how often a service writes what its fixtures ran - a process killed rather than stopped loses at most this much
+const flushEvery = 2 * time.Second
+
+// where records go - nothing is recorded without it
+var dir = os.Getenv("CCC_OBSERVED_DIR")
+
+// Fixture is one test, named the way its runner selects it
+type Fixture struct {
+ Name, File, Language, Kind string
+}
+
+type site struct {
+ file, name string
+ line int
+}
+
+var (
+ mu sync.Mutex
+ // the fixture each goroutine works for - go keeps no goroutine-local storage, so it is kept by goroutine id
+ bound = map[int64]Fixture{}
+ // what a goroutine nobody bound inherited from the goroutine that started it - nil when there was nothing to inherit
+ inherited = map[int64]*Fixture{}
+ // what each fixture ran in this process that is not written yet
+ ran = map[Fixture]map[site]int{}
+ // this process's own tests still running - each writes its record when it ends
+ local = map[Fixture]int{}
+ // one record written at a time, so lines from concurrent fixtures never interleave
+ writing sync.Mutex
+ ticking sync.Once
+)
+
+func init() {
+ if dir != "" {
+ http.DefaultTransport = Transport(http.DefaultTransport)
+ }
+}
+
+// Hit counts one run of a function for the fixture this goroutine works for, if it works for one
+func Hit(file, name string, line int) {
+ if dir == "" {
+ return
+ }
+ ticking.Do(func() { go tick() })
+ f, ok := current(goid())
+ if !ok {
+ return
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if ran[f] == nil {
+ ran[f] = map[site]int{}
+ }
+ ran[f][site{file, name, line}]++
+}
+
+// Test makes a go test a fixture until it and its subtests end - its record is written then
+func Test(t interface {
+ Name() string
+ Cleanup(func())
+}, file, kind string) {
+ if dir == "" {
+ return
+ }
+ f := Fixture{Name: t.Name(), File: file, Language: "go", Kind: kind}
+ unbind := Bind(f)
+ mu.Lock()
+ local[f]++
+ mu.Unlock()
+ start := time.Now()
+ t.Cleanup(func() {
+ unbind()
+ ms := time.Since(start).Milliseconds()
+ mu.Lock()
+ if local[f]--; local[f] <= 0 {
+ delete(local, f)
+ }
+ hits := ran[f]
+ delete(ran, f)
+ mu.Unlock()
+ write(f, hits, &ms)
+ })
+}
+
+// Handler binds each request to the fixture its baggage names, for as long as the request is served - nil serves http.DefaultServeMux
+func Handler(h http.Handler) http.Handler {
+ if dir == "" {
+ return h
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if f, ok := FromBaggage(r.Header.Get("Baggage")); ok {
+ defer Bind(f)()
+ }
+ if h == nil {
+ http.DefaultServeMux.ServeHTTP(w, r)
+ return
+ }
+ h.ServeHTTP(w, r)
+ })
+}
+
+// Transport carries the fixture this goroutine works for on every request it sends - nil wraps http.DefaultTransport
+func Transport(base http.RoundTripper) http.RoundTripper {
+ if dir == "" {
+ return base
+ }
+ if _, done := base.(transport); done {
+ return base
+ }
+ return transport{base: base}
+}
+
+// Flush writes what fixtures ran in this process that no test of its own ended - a service's share of a suite driven from outside
+func Flush() {
+ if dir == "" {
+ return
+ }
+ mu.Lock()
+ pending := map[Fixture]map[site]int{}
+ for f, hits := range ran {
+ if local[f] == 0 {
+ pending[f] = hits
+ delete(ran, f)
+ }
+ }
+ mu.Unlock()
+ for f, hits := range pending {
+ write(f, hits, nil)
+ }
+}
+
+// Bind makes this goroutine work for f until the func it answers is called
+func Bind(f Fixture) func() {
+ id := goid()
+ mu.Lock()
+ prev, had := bound[id]
+ bound[id] = f
+ mu.Unlock()
+ return func() {
+ mu.Lock()
+ defer mu.Unlock()
+ if had {
+ bound[id] = prev
+ } else {
+ delete(bound, id)
+ }
+ }
+}
+
+// Current is the fixture this goroutine works for
+func Current() (Fixture, bool) {
+ if dir == "" {
+ return Fixture{}, false
+ }
+ return current(goid())
+}
+
+// Baggage is f as w3c baggage - the same four members every ccc probe sends
+func (f Fixture) Baggage() string {
+ return strings.Join([]string{
+ "ccc.fixture=" + url.QueryEscape(f.Name),
+ "ccc.file=" + url.QueryEscape(f.File),
+ "ccc.lang=" + url.QueryEscape(f.Language),
+ "ccc.kind=" + url.QueryEscape(f.Kind),
+ }, ",")
+}
+
+// FromBaggage reads the fixture a baggage header names, among whatever other members it carries
+func FromBaggage(header string) (Fixture, bool) {
+ var f Fixture
+ for _, member := range strings.Split(header, ",") {
+ member, _, _ = strings.Cut(member, ";")
+ k, v, ok := strings.Cut(strings.TrimSpace(member), "=")
+ if !ok {
+ continue
+ }
+ v, err := url.QueryUnescape(strings.TrimSpace(v))
+ if err != nil {
+ continue
+ }
+ switch k {
+ case "ccc.fixture":
+ f.Name = v
+ case "ccc.file":
+ f.File = v
+ case "ccc.lang":
+ f.Language = v
+ case "ccc.kind":
+ f.Kind = v
+ }
+ }
+ return f, f.Name != "" && f.Kind != ""
+}
+
+// carries the fixture this goroutine works for on to the next service
+type transport struct {
+ base http.RoundTripper
+}
+
+func (t transport) RoundTrip(r *http.Request) (*http.Response, error) {
+ base := t.base
+ if base == nil {
+ base = http.DefaultTransport
+ }
+ if f, ok := Current(); ok {
+ r = r.Clone(r.Context())
+ r.Header.Set("Baggage", merge(r.Header.Get("Baggage"), f.Baggage()))
+ }
+ return base.RoundTrip(r)
+}
+
+// ccc's members in place of any earlier ones, beside whatever else the header carries
+func merge(header, ours string) string {
+ var kept []string
+ for _, member := range strings.Split(header, ",") {
+ if m := strings.TrimSpace(member); m != "" && !strings.HasPrefix(m, "ccc.") {
+ kept = append(kept, m)
+ }
+ }
+ return strings.Join(append(kept, ours), ",")
+}
+
+// the fixture goroutine id works for - bound to one, inherited from the goroutine that started it, or this process's one running test
+func current(id int64) (Fixture, bool) {
+ mu.Lock()
+ if f, ok := bound[id]; ok {
+ mu.Unlock()
+ return f, true
+ }
+ got, seen := inherited[id]
+ mu.Unlock()
+ if !seen {
+ parent, ok := creator()
+ mu.Lock()
+ if ok {
+ if f, ok := bound[parent]; ok {
+ got = &f
+ } else if f := inherited[parent]; f != nil {
+ got = f
+ }
+ }
+ inherited[id] = got
+ mu.Unlock()
+ }
+ if got != nil {
+ return *got, true
+ }
+ return only()
+}
+
+// the one test this process is running - work no request or parent claims is that test's, and with two or more it is nobody's
+func only() (Fixture, bool) {
+ mu.Lock()
+ defer mu.Unlock()
+ if len(local) != 1 {
+ return Fixture{}, false
+ }
+ for f := range local {
+ return f, true
+ }
+ return Fixture{}, false
+}
+
+// the running goroutine's id - read off the first line of its own stack
+func goid() int64 {
+ var buf [64]byte
+ s := strings.TrimPrefix(string(buf[:runtime.Stack(buf[:], false)]), "goroutine ")
+ id, _ := strconv.ParseInt(s[:strings.IndexByte(s, ' ')], 10, 64)
+ return id
+}
+
+// the goroutine that started this one - go 1.21 and later name it on the last line of the stack
+func creator() (int64, bool) {
+ buf := make([]byte, 16<<10)
+ for {
+ n := runtime.Stack(buf, false)
+ if n < len(buf) {
+ buf = buf[:n]
+ break
+ }
+ buf = make([]byte, len(buf)*2)
+ }
+ s := string(buf)
+ at := strings.LastIndex(s, "\ncreated by ")
+ if at < 0 {
+ return 0, false
+ }
+ line := s[at+1:]
+ if nl := strings.IndexByte(line, '\n'); nl >= 0 {
+ line = line[:nl]
+ }
+ at = strings.LastIndex(line, " in goroutine ")
+ if at < 0 {
+ return 0, false
+ }
+ id, err := strconv.ParseInt(strings.TrimSpace(line[at+len(" in goroutine "):]), 10, 64)
+ return id, err == nil
+}
+
+// a service's records written as it runs - a test binary's tests write their own as they end
+func tick() {
+ for range time.Tick(flushEvery) {
+ Flush()
+ }
+}
+
+type function struct {
+ Name string `json:"name"`
+ Line int `json:"line"`
+ Calls int `json:"calls"`
+}
+
+type file struct {
+ Path string `json:"path"`
+ Functions []function `json:"functions"`
+}
+
+// one ccc-observed/1 line - a fixture, and the functions it ran in this process with how many times each ran
+func write(f Fixture, hits map[site]int, ms *int64) {
+ if len(hits) == 0 && ms == nil {
+ return
+ }
+ byFile := map[string][]function{}
+ for s, n := range hits {
+ byFile[s.file] = append(byFile[s.file], function{Name: s.name, Line: s.line, Calls: n})
+ }
+ files := make([]file, 0, len(byFile))
+ for path, fns := range byFile {
+ sort.Slice(fns, func(i, j int) bool { return fns[i].Line < fns[j].Line })
+ files = append(files, file{Path: path, Functions: fns})
+ }
+ sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
+ service := map[string]string{"name": serviceName()}
+ if c := env("CCC_COMMIT", commit); c != "" {
+ service["commit"] = c
+ }
+ rec := map[string]interface{}{
+ "schema": schema,
+ "fixture": map[string]string{"name": f.Name, "kind": f.Kind, "file": f.File, "language": f.Language},
+ "run": env("CCC_RUN", "local"),
+ "service": service,
+ "files": files,
+ }
+ if ms != nil {
+ rec["duration_ms"] = *ms
+ }
+ line, err := json.Marshal(rec)
+ if err != nil {
+ return
+ }
+ writing.Lock()
+ defer writing.Unlock()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return
+ }
+ out, err := os.OpenFile(filepath.Join(dir, fmt.Sprintf("%s-%d.jsonl", serviceName(), os.Getpid())), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return
+ }
+ defer out.Close()
+ _, _ = out.Write(append(line, '\n'))
+}
+
+func serviceName() string {
+ return env("CCC_SERVICE", strings.TrimSuffix(filepath.Base(os.Args[0]), ".test"))
+}
+
+func env(key, fallback string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return fallback
+}
diff --git a/probes/go/cccprobegrpc/cccprobegrpc.go b/probes/go/cccprobegrpc/cccprobegrpc.go
new file mode 100644
index 0000000..d9d9d67
--- /dev/null
+++ b/probes/go/cccprobegrpc/cccprobegrpc.go
@@ -0,0 +1,61 @@
+// carries ccc's fixture over grpc - `ccc probe go` writes this into a module only when it requires google.golang.org/grpc
+package cccprobegrpc
+
+import (
+ "context"
+ "strings"
+
+ cccprobe "ccc.invalid/probe"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/metadata"
+)
+
+// UnaryServer binds each unary call a server takes to the fixture its metadata names
+func UnaryServer() grpc.ServerOption {
+ return grpc.ChainUnaryInterceptor(func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
+ if f, ok := incoming(ctx); ok {
+ defer cccprobe.Bind(f)()
+ }
+ return handler(ctx, req)
+ })
+}
+
+// StreamServer binds each stream a server takes to the fixture its metadata names
+func StreamServer() grpc.ServerOption {
+ return grpc.ChainStreamInterceptor(func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
+ if f, ok := incoming(ss.Context()); ok {
+ defer cccprobe.Bind(f)()
+ }
+ return handler(srv, ss)
+ })
+}
+
+// UnaryClient carries the caller's fixture on every unary call
+func UnaryClient() grpc.DialOption {
+ return grpc.WithChainUnaryInterceptor(func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
+ return invoker(outgoing(ctx), method, req, reply, cc, opts...)
+ })
+}
+
+// StreamClient carries the caller's fixture on every stream it opens
+func StreamClient() grpc.DialOption {
+ return grpc.WithChainStreamInterceptor(func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
+ return streamer(outgoing(ctx), desc, cc, method, opts...)
+ })
+}
+
+func incoming(ctx context.Context) (cccprobe.Fixture, bool) {
+ md, ok := metadata.FromIncomingContext(ctx)
+ if !ok {
+ return cccprobe.Fixture{}, false
+ }
+ return cccprobe.FromBaggage(strings.Join(md.Get("baggage"), ","))
+}
+
+func outgoing(ctx context.Context) context.Context {
+ f, ok := cccprobe.Current()
+ if !ok {
+ return ctx
+ }
+ return metadata.AppendToOutgoingContext(ctx, "baggage", f.Baggage())
+}
diff --git a/probes/go/commit.go b/probes/go/commit.go
new file mode 100644
index 0000000..89c3cf8
--- /dev/null
+++ b/probes/go/commit.go
@@ -0,0 +1,5 @@
+package cccprobe
+
+// the commit the copy was made from - CCC_COMMIT overrides it
+// a stand-in so this directory builds on its own - `ccc probe go` writes the real one into the copy and never embeds this
+const commit = ""
diff --git a/probes/python/ccc_probe.py b/probes/python/ccc_probe.py
new file mode 100644
index 0000000..0aed724
--- /dev/null
+++ b/probes/python/ccc_probe.py
@@ -0,0 +1,646 @@
+# ccc's python probe - records which functions ran on behalf of which test fixture and how many times, as ccc-observed/1 lines
+# off unless CCC_OBSERVED_DIR names a directory - `ccc probe python` puts the calls into a copy of the project and this module beside it
+import atexit
+import collections
+import contextvars
+import importlib.abc
+import importlib.util
+import inspect
+import json
+import os
+import signal
+import sys
+import threading
+import time
+import urllib.parse
+
+SCHEMA = "ccc-observed/1"
+DIR = os.environ.get("CCC_OBSERVED_DIR", "")
+# how often a service writes what its fixtures ran - a process killed rather than stopped loses at most this much
+FLUSH_SECONDS = 2.0
+# the copy's root - this file lives at /.ccc-probe/python/ccc_probe.py
+ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+# the commit the copy was made from, the copy's path inside the project, and each test file's kind - `ccc probe` fills them in
+COMMIT = ""
+PREFIX = ""
+KINDS = {}
+
+# the fixture a request or a thread works for
+_bound = contextvars.ContextVar("ccc_fixture", default=None)
+# the test this process is running - pytest and unittest run one at a time, and its worker threads work for it too
+_test = None
+_lock = threading.Lock()
+_writing = threading.Lock()
+# what each fixture ran that is not written yet
+_ran = {}
+_pytest = False
+_started = False
+
+
+# one test, named the way its runner selects it
+class Fixture(collections.namedtuple("Fixture", ("name", "file", "language", "kind"))):
+ __slots__ = ()
+
+
+# the fixture this work runs for - bound by a request, else this process's running test
+def current():
+ if not DIR:
+ return None
+ return _bound.get() or _test
+
+
+# counts one run of a function for the fixture this work runs for
+def hit(file, name, line):
+ if not DIR:
+ return
+ f = _bound.get() or _test
+ if f is None:
+ return
+ site = (file, name, line)
+ with _lock:
+ sites = _ran.get(f)
+ if sites is None:
+ sites = _ran[f] = {}
+ sites[site] = sites.get(site, 0) + 1
+
+
+# runs fn as work for f
+def bound(f, fn, *args, **kwargs):
+ if f is None:
+ return fn(*args, **kwargs)
+ token = _bound.set(f)
+ try:
+ return fn(*args, **kwargs)
+ finally:
+ _bound.reset(token)
+
+
+# the fixture as w3c baggage - the same four members every ccc probe sends
+def baggage(f):
+ members = {"fixture": f.name, "file": f.file, "lang": f.language, "kind": f.kind}
+ return ",".join(f"ccc.{k}={urllib.parse.quote(v, safe='')}" for k, v in members.items())
+
+
+# the fixture a baggage header names, among whatever other members it carries
+def from_baggage(header):
+ if isinstance(header, bytes):
+ header = header.decode("latin-1")
+ if not header:
+ return None
+ got = {}
+ for member in str(header).split(","):
+ k, sep, v = member.split(";", 1)[0].partition("=")
+ if sep:
+ got[k.strip()] = urllib.parse.unquote_plus(v.strip())
+ name, kind = got.get("ccc.fixture"), got.get("ccc.kind")
+ if not name or not kind:
+ return None
+ return Fixture(name, got.get("ccc.file", ""), got.get("ccc.lang", "python"), kind)
+
+
+# ccc's members in place of any earlier ones, beside whatever else the header carries
+def _merge(header, ours):
+ kept = [m.strip() for m in str(header or "").split(",") if m.strip() and not m.strip().startswith("ccc.")]
+ return ",".join(kept + [ours])
+
+
+def _start_test(name, path):
+ global _test
+ path = os.path.abspath(str(path))
+ rel = os.path.relpath(path, ROOT).replace(os.sep, "/")
+ file = path.replace(os.sep, "/") if rel.startswith("../") else PREFIX + rel
+ _test = Fixture(name, file, "python", KINDS.get(rel, "unit"))
+ return _test
+
+
+def _end_test(f, seconds):
+ global _test
+ if _test == f:
+ _test = None
+ with _lock:
+ sites = _ran.pop(f, {})
+ _write(f, sites, round(seconds * 1000))
+
+
+# writes what fixtures ran in this process that no test of its own is still running
+def flush():
+ if not DIR:
+ return
+ with _lock:
+ pending = {f: s for f, s in _ran.items() if f != _test}
+ for f in pending:
+ del _ran[f]
+ for f, sites in pending.items():
+ _write(f, sites, None)
+
+
+def _service():
+ if os.environ.get("CCC_SERVICE"):
+ return os.environ["CCC_SERVICE"]
+ if _pytest:
+ return "pytest"
+ name = os.path.basename(sys.argv[0] if sys.argv and sys.argv[0] else "python")
+ return os.path.splitext(name)[0] or "python"
+
+
+# one ccc-observed/1 line - a fixture, and the functions it ran in this process with how many times each ran
+def _write(f, sites, ms):
+ if not sites and ms is None:
+ return
+ files = {}
+ for (path, name, line), calls in sites.items():
+ files.setdefault(path, []).append({"name": name, "line": line, "calls": calls})
+ service = {"name": _service()}
+ commit = os.environ.get("CCC_COMMIT", COMMIT)
+ if commit:
+ service["commit"] = commit
+ record = {
+ "schema": SCHEMA,
+ "fixture": {"name": f.name, "kind": f.kind, "file": f.file, "language": f.language},
+ "run": os.environ.get("CCC_RUN", "local"),
+ "service": service,
+ "files": [{"path": p, "functions": sorted(fns, key=lambda x: x["line"])} for p, fns in sorted(files.items())],
+ }
+ if ms is not None:
+ record["duration_ms"] = ms
+ line = json.dumps(record, separators=(",", ":")) + "\n"
+ try:
+ with _writing:
+ os.makedirs(DIR, exist_ok=True)
+ with open(os.path.join(DIR, f"{_service()}-{os.getpid()}.jsonl"), "a", encoding="utf-8") as out:
+ out.write(line)
+ except OSError:
+ pass
+
+
+# registers with the pytest running this process, if one is - every probed test file and conftest calls it as it loads
+def attach():
+ global _pytest
+ if _pytest or not DIR or "_pytest.config" not in sys.modules:
+ return
+ import gc
+
+ from _pytest.config import Config
+
+ config = next((o for o in gc.get_objects() if isinstance(o, Config)), None)
+ if config is None:
+ return
+ _pytest = True
+ if config.pluginmanager.has_plugin("ccc_probe"):
+ return
+ import pytest
+
+ # each test a fixture from setup to teardown, timed over its own call phase
+ class Plugin:
+ def __init__(self):
+ self.durations = {}
+
+ @pytest.hookimpl(hookwrapper=True)
+ def pytest_runtest_protocol(self, item, nextitem):
+ path = getattr(item, "path", None) or getattr(item, "fspath", "")
+ f = _start_test(getattr(item, "originalname", None) or item.name, path)
+ try:
+ yield
+ finally:
+ _end_test(f, self.durations.pop(item.nodeid, 0.0))
+
+ def pytest_runtest_logreport(self, report):
+ if report.when == "call":
+ self.durations[report.nodeid] = report.duration
+
+ config.pluginmanager.register(Plugin(), "ccc_probe")
+
+
+# a unittest run outside pytest - each test method a fixture
+def _patch_unittest():
+ import unittest
+
+ run = unittest.TestCase.run
+ if getattr(run, "_ccc", False):
+ return
+
+ def patched(self, result=None):
+ if _pytest:
+ return run(self, result)
+ try:
+ path = inspect.getsourcefile(type(self)) or ""
+ except TypeError:
+ path = ""
+ f = _start_test(getattr(self, "_testMethodName", type(self).__name__), path)
+ start = time.perf_counter()
+ try:
+ return run(self, result)
+ finally:
+ _end_test(f, time.perf_counter() - start)
+
+ patched._ccc = True
+ unittest.TestCase.run = patched
+
+
+# threads and pool workers carry the fixture of whoever started them
+def _patch_threads():
+ start = threading.Thread.start
+ if getattr(start, "_ccc", False):
+ return
+
+ def patched_start(self):
+ ctx = contextvars.copy_context()
+ run = self.run
+ self.run = lambda: ctx.run(run)
+ return start(self)
+
+ patched_start._ccc = True
+ threading.Thread.start = patched_start
+ import concurrent.futures
+
+ submit = concurrent.futures.ThreadPoolExecutor.submit
+
+ def patched_submit(self, fn, /, *args, **kwargs):
+ return submit(self, contextvars.copy_context().run, fn, *args, **kwargs)
+
+ concurrent.futures.ThreadPoolExecutor.submit = patched_submit
+
+
+# every request http.client sends - urllib, requests and urllib3 among them - carries the fixture it is sent for
+def _patch_http_client(mod):
+ putrequest = mod.HTTPConnection.putrequest
+ if getattr(putrequest, "_ccc", False):
+ return
+
+ def patched(self, *args, **kwargs):
+ putrequest(self, *args, **kwargs)
+ f = current()
+ if f is not None:
+ self.putheader("baggage", baggage(f))
+
+ patched._ccc = True
+ mod.HTTPConnection.putrequest = patched
+
+
+def _patch_httpx(mod):
+ send = mod.Client.send
+ asend = mod.AsyncClient.send
+
+ def patched(self, request, *args, **kwargs):
+ f = current()
+ if f is not None:
+ request.headers["baggage"] = _merge(request.headers.get("baggage"), baggage(f))
+ return send(self, request, *args, **kwargs)
+
+ async def apatched(self, request, *args, **kwargs):
+ f = current()
+ if f is not None:
+ request.headers["baggage"] = _merge(request.headers.get("baggage"), baggage(f))
+ return await asend(self, request, *args, **kwargs)
+
+ mod.Client.send = patched
+ mod.AsyncClient.send = apatched
+
+
+def _patch_aiohttp(mod):
+ request = mod.ClientSession._request
+
+ async def patched(self, method, url, **kwargs):
+ f = current()
+ if f is not None:
+ headers = dict(kwargs.get("headers") or {})
+ headers["baggage"] = _merge(headers.get("baggage"), baggage(f))
+ kwargs["headers"] = headers
+ return await request(self, method, url, **kwargs)
+
+ mod.ClientSession._request = patched
+
+
+# a wsgi app that serves each request for the fixture its baggage names
+def _wsgi(app):
+ def served(environ, start_response):
+ return bound(from_baggage(environ.get("HTTP_BAGGAGE")), app, environ, start_response)
+
+ return served
+
+
+# an asgi call that serves each request for the fixture its baggage names
+def _asgi(call):
+ async def served(self, scope, receive, send):
+ f = None
+ if scope.get("type") in ("http", "websocket"):
+ for k, v in scope.get("headers") or ():
+ if k.lower() == b"baggage":
+ f = from_baggage(v)
+ if f is None:
+ return await call(self, scope, receive, send)
+ token = _bound.set(f)
+ try:
+ return await call(self, scope, receive, send)
+ finally:
+ _bound.reset(token)
+
+ return served
+
+
+def _patch_method(cls, name, wrap):
+ method = getattr(cls, name)
+ if getattr(method, "_ccc", False):
+ return
+ patched = wrap(method)
+ patched._ccc = True
+ setattr(cls, name, patched)
+
+
+def _patch_flask(mod):
+ _patch_method(mod.Flask, "wsgi_app", lambda m: lambda self, environ, start_response: _wsgi(lambda e, s: m(self, e, s))(environ, start_response))
+
+
+def _patch_django_wsgi(mod):
+ _patch_method(mod.WSGIHandler, "__call__", lambda m: lambda self, environ, start_response: _wsgi(lambda e, s: m(self, e, s))(environ, start_response))
+
+
+def _patch_wsgiref(mod):
+ _patch_method(mod.BaseHandler, "run", lambda m: lambda self, application: m(self, _wsgi(application)))
+
+
+def _patch_starlette(mod):
+ _patch_method(mod.Starlette, "__call__", _asgi)
+
+
+def _patch_django_asgi(mod):
+ _patch_method(mod.ASGIHandler, "__call__", _asgi)
+
+
+# http.server - each request a handler parses is served for the fixture its baggage names
+def _patch_http_server(mod):
+ handler = mod.BaseHTTPRequestHandler
+ parse = handler.parse_request
+ one = handler.handle_one_request
+ if getattr(one, "_ccc", False):
+ return
+
+ def parse_request(self):
+ ok = parse(self)
+ if ok:
+ f = from_baggage(self.headers.get("baggage"))
+ if f is not None:
+ self._ccc_token = _bound.set(f)
+ return ok
+
+ def handle_one_request(self):
+ try:
+ return one(self)
+ finally:
+ token = self.__dict__.pop("_ccc_token", None)
+ if token is not None:
+ _bound.reset(token)
+
+ handle_one_request._ccc = True
+ handler.parse_request = parse_request
+ handler.handle_one_request = handle_one_request
+
+
+# the caller's interceptors with one more on the end - however the caller passed them
+def _with_interceptor(fn, at, interceptor):
+ def patched(*args, **kwargs):
+ if len(args) > at:
+ args = list(args)
+ args[at] = list(args[at] or ()) + [interceptor]
+ else:
+ kwargs["interceptors"] = list(kwargs.get("interceptors") or ()) + [interceptor]
+ return fn(*args, **kwargs)
+
+ return patched
+
+
+def _metadata_fixture(metadata):
+ for k, v in metadata or ():
+ if k == "baggage":
+ return from_baggage(v)
+ return None
+
+
+# grpc - every call a server takes runs for the fixture its metadata names, every call a client makes carries its own
+def _patch_grpc(grpc):
+ if getattr(grpc, "_ccc", False):
+ return
+ grpc._ccc = True
+
+ def unary(behaviour, f):
+ return lambda request, context: bound(f, behaviour, request, context)
+
+ def streaming(behaviour, f):
+ def served(request, context):
+ token = _bound.set(f)
+ try:
+ yield from behaviour(request, context)
+ finally:
+ _bound.reset(token)
+
+ return served
+
+ class ServerInterceptor(grpc.ServerInterceptor):
+ def intercept_service(self, continuation, details):
+ handler = continuation(details)
+ f = _metadata_fixture(details.invocation_metadata)
+ if handler is None or f is None:
+ return handler
+ changes = {}
+ for field, wrap in (("unary_unary", unary), ("stream_unary", unary), ("unary_stream", streaming), ("stream_stream", streaming)):
+ behaviour = getattr(handler, field, None)
+ if behaviour is not None:
+ changes[field] = wrap(behaviour, f)
+ return handler._replace(**changes)
+
+ Details = collections.namedtuple("Details", ("method", "timeout", "metadata", "credentials", "wait_for_ready", "compression"))
+
+ class CallDetails(Details, grpc.ClientCallDetails):
+ pass
+
+ def outgoing(details):
+ f = current()
+ if f is None:
+ return details
+ metadata = [(k, v) for k, v in (details.metadata or ()) if k != "baggage"] + [("baggage", baggage(f))]
+ return CallDetails(details.method, details.timeout, metadata, details.credentials, getattr(details, "wait_for_ready", None), getattr(details, "compression", None))
+
+ class ClientInterceptor(grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor, grpc.StreamUnaryClientInterceptor, grpc.StreamStreamClientInterceptor):
+ def intercept_unary_unary(self, continuation, details, request):
+ return continuation(outgoing(details), request)
+
+ def intercept_unary_stream(self, continuation, details, request):
+ return continuation(outgoing(details), request)
+
+ def intercept_stream_unary(self, continuation, details, requests):
+ return continuation(outgoing(details), requests)
+
+ def intercept_stream_stream(self, continuation, details, requests):
+ return continuation(outgoing(details), requests)
+
+ def intercepted(fn):
+ return lambda *args, **kwargs: grpc.intercept_channel(fn(*args, **kwargs), ClientInterceptor())
+
+ grpc.server = _with_interceptor(grpc.server, 2, ServerInterceptor())
+ grpc.insecure_channel = intercepted(grpc.insecure_channel)
+ grpc.secure_channel = intercepted(grpc.secure_channel)
+ aio = sys.modules.get("grpc.aio")
+ if aio is not None:
+ _patch_grpc_aio(aio)
+
+
+# grpc's asyncio api - the same, through its own interceptor classes and metadata type
+def _patch_grpc_aio(aio):
+ if getattr(aio, "_ccc", False):
+ return
+ aio._ccc = True
+
+ def unary(behaviour, f):
+ async def served(request, context):
+ token = _bound.set(f)
+ try:
+ return await behaviour(request, context)
+ finally:
+ _bound.reset(token)
+
+ return served
+
+ def streaming(behaviour, f):
+ async def served(request, context):
+ token = _bound.set(f)
+ try:
+ async for item in behaviour(request, context):
+ yield item
+ finally:
+ _bound.reset(token)
+
+ return served
+
+ class ServerInterceptor(aio.ServerInterceptor):
+ async def intercept_service(self, continuation, details):
+ handler = await continuation(details)
+ f = _metadata_fixture(details.invocation_metadata)
+ if handler is None or f is None:
+ return handler
+ changes = {}
+ for field, wrap in (("unary_unary", unary), ("stream_unary", unary), ("unary_stream", streaming), ("stream_stream", streaming)):
+ behaviour = getattr(handler, field, None)
+ if behaviour is not None:
+ changes[field] = wrap(behaviour, f)
+ return handler._replace(**changes)
+
+ def outgoing(details):
+ f = current()
+ if f is None:
+ return details
+ kept = [(k, v) for k, v in (details.metadata or ()) if k != "baggage"]
+ return details._replace(metadata=aio.Metadata(*kept, ("baggage", baggage(f))))
+
+ class ClientInterceptor(aio.UnaryUnaryClientInterceptor, aio.UnaryStreamClientInterceptor, aio.StreamUnaryClientInterceptor, aio.StreamStreamClientInterceptor):
+ async def intercept_unary_unary(self, continuation, details, request):
+ return await continuation(outgoing(details), request)
+
+ async def intercept_unary_stream(self, continuation, details, request):
+ return await continuation(outgoing(details), request)
+
+ async def intercept_stream_unary(self, continuation, details, requests):
+ return await continuation(outgoing(details), requests)
+
+ async def intercept_stream_stream(self, continuation, details, requests):
+ return await continuation(outgoing(details), requests)
+
+ aio.server = _with_interceptor(aio.server, 2, ServerInterceptor())
+ aio.insecure_channel = _with_interceptor(aio.insecure_channel, 3, ClientInterceptor())
+ aio.secure_channel = _with_interceptor(aio.secure_channel, 4, ClientInterceptor())
+
+
+# modules patched as they are imported - or at once, when one already is
+_PATCHES = {
+ "http.server": _patch_http_server,
+ "wsgiref.handlers": _patch_wsgiref,
+ "httpx": _patch_httpx,
+ "aiohttp": _patch_aiohttp,
+ "flask": _patch_flask,
+ "django.core.handlers.wsgi": _patch_django_wsgi,
+ "django.core.handlers.asgi": _patch_django_asgi,
+ "starlette.applications": _patch_starlette,
+ "grpc": _patch_grpc,
+ "grpc.aio": _patch_grpc_aio,
+}
+
+
+class _Loader(importlib.abc.Loader):
+ def __init__(self, loader, patch):
+ self._loader = loader
+ self._patch = patch
+
+ def create_module(self, spec):
+ return self._loader.create_module(spec)
+
+ def exec_module(self, module):
+ self._loader.exec_module(module)
+ _apply(self._patch, module)
+
+ def __getattr__(self, name):
+ return getattr(self._loader, name)
+
+
+class _Finder(importlib.abc.MetaPathFinder):
+ def __init__(self):
+ self._finding = set()
+
+ def find_spec(self, name, path, target=None):
+ patch = _PATCHES.get(name)
+ if patch is None or name in self._finding:
+ return None
+ self._finding.add(name)
+ try:
+ spec = importlib.util.find_spec(name)
+ finally:
+ self._finding.discard(name)
+ if spec is None or spec.loader is None or not hasattr(spec.loader, "exec_module"):
+ return None
+ spec.loader = _Loader(spec.loader, patch)
+ return spec
+
+
+def _apply(patch, module):
+ try:
+ patch(module)
+ except Exception:
+ # a library shaped differently than expected keeps working, unprobed
+ pass
+
+
+def _tick():
+ while True:
+ time.sleep(FLUSH_SECONDS)
+ flush()
+
+
+def _install():
+ global _started
+ if _started or not DIR:
+ return
+ _started = True
+ import http.client
+
+ _apply(_patch_http_client, http.client)
+ _apply(lambda _: _patch_threads(), None)
+ _apply(lambda _: _patch_unittest(), None)
+ for name, patch in _PATCHES.items():
+ if name in sys.modules:
+ _apply(patch, sys.modules[name])
+ sys.meta_path.insert(0, _Finder())
+ atexit.register(flush)
+ threading.Thread(target=_tick, name="ccc-probe-flush", daemon=True).start()
+ # a terminating signal nobody else handles still writes the records before it ends the process
+ try:
+ if threading.current_thread() is threading.main_thread() and signal.getsignal(signal.SIGTERM) == signal.SIG_DFL:
+
+ def terminated(signum, frame):
+ flush()
+ signal.signal(signum, signal.SIG_DFL)
+ os.kill(os.getpid(), signum)
+
+ signal.signal(signal.SIGTERM, terminated)
+ except (ValueError, OSError, AttributeError):
+ pass
+
+
+_install()
diff --git a/probes/rust/Cargo.toml b/probes/rust/Cargo.toml
new file mode 100644
index 0000000..0bbd9e3
--- /dev/null
+++ b/probes/rust/Cargo.toml
@@ -0,0 +1,19 @@
+# ccc's rust probe - written into an instrumented copy by `ccc probe rust`, never published
+[package]
+name = "ccc_probe"
+version = "0.0.0"
+edition = "2021"
+rust-version = "1.70"
+publish = false
+
+[lib]
+path = "lib.rs"
+
+[features]
+# a tower layer for axum and tonic servers - turned on for packages that depend on either
+tower = ["dep:http", "dep:tower-layer", "dep:tower-service"]
+
+[dependencies]
+http = { version = "1", optional = true }
+tower-layer = { version = "0.3", optional = true }
+tower-service = { version = "0.3", optional = true }
diff --git a/probes/rust/lib.rs b/probes/rust/lib.rs
new file mode 100644
index 0000000..d068dde
--- /dev/null
+++ b/probes/rust/lib.rs
@@ -0,0 +1,408 @@
+//! ccc's rust probe - records which functions ran on behalf of which test fixture and how many times, as ccc-observed/1 lines
+//! off unless CCC_OBSERVED_DIR names a directory - `ccc probe rust` puts the calls into a copy of the crate and this crate beside it
+
+use std::cell::RefCell;
+use std::collections::HashMap;
+use std::future::Future;
+use std::io::Write;
+use std::pin::Pin;
+use std::sync::{Arc, Mutex, MutexGuard, Once, OnceLock};
+use std::task::{Context, Poll};
+use std::time::{Duration, Instant};
+
+const SCHEMA: &str = "ccc-observed/1";
+// how often a service writes what its fixtures ran - a process killed rather than stopped loses at most this much
+const FLUSH_EVERY: Duration = Duration::from_secs(2);
+// the commit the copy was made from - CCC_COMMIT overrides it
+const COMMIT: &str = "";
+
+// one test, named the way its runner selects it
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub struct Fixture {
+ pub name: String,
+ pub file: String,
+ pub language: String,
+ pub kind: String,
+}
+
+type Site = (&'static str, &'static str, u32);
+
+#[derive(Default)]
+struct State {
+ // what each fixture ran that is not written yet
+ ran: HashMap, HashMap>,
+ // this process's own tests still running - each writes its record when it ends
+ local: HashMap, usize>,
+}
+
+thread_local! {
+ // the fixture this thread works for
+ static BOUND: RefCell>> = const { RefCell::new(None) };
+}
+
+fn dir() -> Option<&'static str> {
+ static DIR: OnceLock > = OnceLock::new();
+ DIR.get_or_init(|| std::env::var("CCC_OBSERVED_DIR").ok().filter(|d| !d.is_empty())).as_deref()
+}
+
+fn state() -> MutexGuard<'static, State> {
+ static STATE: OnceLock> = OnceLock::new();
+ STATE.get_or_init(Default::default).lock().unwrap_or_else(|p| p.into_inner())
+}
+
+// the fixture this work runs for - bound to this thread, or this process's one running test
+pub fn current() -> Option> {
+ dir()?;
+ if let Some(f) = BOUND.try_with(|b| b.borrow().clone()).ok().flatten() {
+ return Some(f);
+ }
+ let s = state();
+ if s.local.len() == 1 {
+ return s.local.keys().next().cloned();
+ }
+ None
+}
+
+// counts one run of a function for the fixture this work runs for
+pub fn hit(file: &'static str, name: &'static str, line: u32) {
+ if dir().is_none() {
+ return;
+ }
+ tick();
+ if let Some(f) = current() {
+ *state().ran.entry(f).or_default().entry((file, name, line)).or_default() += 1;
+ }
+}
+
+// makes this thread work for a fixture until it drops - `None` leaves whatever was bound
+pub struct Bind {
+ prev: Option>>,
+}
+
+pub fn bind(f: Option>) -> Bind {
+ match f {
+ Some(f) => Bind { prev: BOUND.try_with(|b| b.replace(Some(f))).ok() },
+ None => Bind { prev: None },
+ }
+}
+
+impl Drop for Bind {
+ fn drop(&mut self) {
+ if let Some(prev) = self.prev.take() {
+ let _ = BOUND.try_with(|b| b.replace(prev));
+ }
+ }
+}
+
+// a test as a fixture until it drops - its record is written then
+pub struct Test {
+ open: Option<(Arc, Instant, Bind)>,
+}
+
+pub fn fixture(name: &'static str, file: &'static str, kind: &'static str) -> Test {
+ if dir().is_none() {
+ return Test { open: None };
+ }
+ let f = Arc::new(Fixture { name: name.into(), file: file.into(), language: "rust".into(), kind: kind.into() });
+ *state().local.entry(f.clone()).or_default() += 1;
+ let bound = bind(Some(f.clone()));
+ Test { open: Some((f, Instant::now(), bound)) }
+}
+
+impl Drop for Test {
+ fn drop(&mut self) {
+ let Some((f, start, bound)) = self.open.take() else { return };
+ drop(bound);
+ let sites = {
+ let mut s = state();
+ if let Some(n) = s.local.get_mut(&f) {
+ *n -= 1;
+ if *n == 0 {
+ s.local.remove(&f);
+ }
+ }
+ s.ran.remove(&f).unwrap_or_default()
+ };
+ write(&f, &sites, Some(start.elapsed().as_millis() as u64));
+ }
+}
+
+// a future that works for a fixture on every poll, whichever thread polls it
+pub struct Bound {
+ fixture: Option>,
+ inner: Pin>,
+}
+
+pub fn bound(fixture: Option>, inner: F) -> Bound {
+ Bound { fixture, inner: Box::pin(inner) }
+}
+
+impl Future for Bound {
+ type Output = F::Output;
+
+ fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll {
+ let this = self.get_mut();
+ let _bound = bind(this.fixture.clone());
+ this.inner.as_mut().poll(cx)
+ }
+}
+
+// a spawned future carries the fixture of whoever spawned it
+pub fn bind_current(inner: F) -> Bound {
+ bound(current(), inner)
+}
+
+// a spawned closure carries the fixture of whoever spawned it
+pub fn bind_current_fn R, R>(f: F) -> impl FnOnce() -> R {
+ let fixture = current();
+ move || {
+ let _bound = bind(fixture);
+ f()
+ }
+}
+
+// the fixture this work runs for as w3c baggage - the same four members every ccc probe sends
+pub fn baggage() -> Option {
+ current().map(|f| to_baggage(&f))
+}
+
+pub fn to_baggage(f: &Fixture) -> String {
+ format!(
+ "ccc.fixture={},ccc.file={},ccc.lang={},ccc.kind={}",
+ encode(&f.name),
+ encode(&f.file),
+ encode(&f.language),
+ encode(&f.kind)
+ )
+}
+
+// the fixture a baggage header names, among whatever other members it carries
+pub fn from_baggage(header: Option<&str>) -> Option> {
+ let mut f = Fixture { name: String::new(), file: String::new(), language: String::new(), kind: String::new() };
+ for member in header?.split(',') {
+ let pair = member.split(';').next().unwrap_or_default();
+ let Some((k, v)) = pair.split_once('=') else { continue };
+ let v = decode(v.trim());
+ match k.trim() {
+ "ccc.fixture" => f.name = v,
+ "ccc.file" => f.file = v,
+ "ccc.lang" => f.language = v,
+ "ccc.kind" => f.kind = v,
+ _ => {}
+ }
+ }
+ (!f.name.is_empty() && !f.kind.is_empty()).then(|| Arc::new(f))
+}
+
+// `t` with the baggage header set by `set`, when this work runs for a fixture - a request builder on its way out
+pub fn with_baggage(t: T, set: impl FnOnce(T, String) -> T) -> T {
+ match baggage() {
+ Some(b) => set(t, b),
+ None => t,
+ }
+}
+
+// writes what fixtures ran in this process when the guard drops - put atop main
+pub struct Main;
+
+pub fn main_guard() -> Main {
+ Main
+}
+
+impl Drop for Main {
+ fn drop(&mut self) {
+ flush();
+ }
+}
+
+// writes what fixtures ran in this process that no test of its own is still running
+pub fn flush() {
+ if dir().is_none() {
+ return;
+ }
+ let pending: Vec<(Arc, HashMap)> = {
+ let mut s = state();
+ let done: Vec> = s.ran.keys().filter(|f| !s.local.contains_key(*f)).cloned().collect();
+ done.into_iter().filter_map(|f| s.ran.remove(&f).map(|sites| (f, sites))).collect()
+ };
+ for (f, sites) in pending {
+ write(&f, &sites, None);
+ }
+}
+
+fn tick() {
+ static TICKING: Once = Once::new();
+ TICKING.call_once(|| {
+ let _ = std::thread::Builder::new().name("ccc-probe-flush".into()).spawn(|| loop {
+ std::thread::sleep(FLUSH_EVERY);
+ flush();
+ });
+ });
+}
+
+fn encode(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for b in s.bytes() {
+ if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) {
+ out.push(b as char);
+ } else {
+ out.push_str(&format!("%{b:02X}"));
+ }
+ }
+ out
+}
+
+fn decode(s: &str) -> String {
+ let bytes = s.as_bytes();
+ let mut out = Vec::with_capacity(bytes.len());
+ let mut i = 0;
+ while i < bytes.len() {
+ match bytes[i] {
+ b'+' => out.push(b' '),
+ b'%' if i + 2 < bytes.len() => match (hex(bytes[i + 1]), hex(bytes[i + 2])) {
+ (Some(hi), Some(lo)) => {
+ out.push(hi * 16 + lo);
+ i += 2;
+ }
+ _ => out.push(b'%'),
+ },
+ b => out.push(b),
+ }
+ i += 1;
+ }
+ String::from_utf8_lossy(&out).into_owned()
+}
+
+fn hex(b: u8) -> Option {
+ (b as char).to_digit(16).map(|d| d as u8)
+}
+
+fn json(s: &str) -> String {
+ let mut out = String::with_capacity(s.len() + 2);
+ out.push('"');
+ for c in s.chars() {
+ match c {
+ '"' => out.push_str("\\\""),
+ '\\' => out.push_str("\\\\"),
+ c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
+ c => out.push(c),
+ }
+ }
+ out.push('"');
+ out
+}
+
+fn service() -> String {
+ if let Ok(s) = std::env::var("CCC_SERVICE") {
+ if !s.is_empty() {
+ return s;
+ }
+ }
+ let exe = std::env::current_exe().ok().and_then(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned())).unwrap_or_else(|| "rust".into());
+ // a test binary is `-<16 hex digits>`
+ match exe.rsplit_once('-') {
+ Some((name, hash)) if hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit()) => name.to_string(),
+ _ => exe,
+ }
+}
+
+// one ccc-observed/1 line - a fixture, and the functions it ran in this process with how many times each ran
+fn write(f: &Fixture, sites: &HashMap, ms: Option) {
+ let Some(dir) = dir() else { return };
+ if sites.is_empty() && ms.is_none() {
+ return;
+ }
+ let mut files: std::collections::BTreeMap<&str, std::collections::BTreeMap<(u32, &str), u64>> = Default::default();
+ for (&(file, name, line), &calls) in sites {
+ *files.entry(file).or_default().entry((line, name)).or_default() += calls;
+ }
+ let files: Vec = files
+ .iter()
+ .map(|(path, fns)| {
+ let fns: Vec = fns.iter().map(|((line, name), calls)| format!("{{\"name\":{},\"line\":{line},\"calls\":{calls}}}", json(name))).collect();
+ format!("{{\"path\":{},\"functions\":[{}]}}", json(path), fns.join(","))
+ })
+ .collect();
+ let commit = std::env::var("CCC_COMMIT").ok().filter(|c| !c.is_empty()).unwrap_or_else(|| COMMIT.to_string());
+ let commit = if commit.is_empty() { String::new() } else { format!(",\"commit\":{}", json(&commit)) };
+ let run = std::env::var("CCC_RUN").ok().filter(|r| !r.is_empty()).unwrap_or_else(|| "local".into());
+ let duration = ms.map(|ms| format!(",\"duration_ms\":{ms}")).unwrap_or_default();
+ let line = format!(
+ "{{\"schema\":{},\"fixture\":{{\"name\":{},\"kind\":{},\"file\":{},\"language\":{}}},\"run\":{},\"service\":{{\"name\":{}{commit}}},\"files\":[{}]{duration}}}\n",
+ json(SCHEMA),
+ json(&f.name),
+ json(&f.kind),
+ json(&f.file),
+ json(&f.language),
+ json(&run),
+ json(&service()),
+ files.join(",")
+ );
+ static WRITING: Mutex<()> = Mutex::new(());
+ let _writing = WRITING.lock().unwrap_or_else(|p| p.into_inner());
+ let _ = std::fs::create_dir_all(dir);
+ let path = std::path::Path::new(dir).join(format!("{}-{}.jsonl", service(), std::process::id()));
+ if let Ok(mut out) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
+ let _ = out.write_all(line.as_bytes());
+ }
+}
+
+// a tower layer that serves each request for the fixture its baggage names - axum, tonic and anything else built on tower
+#[cfg(feature = "tower")]
+pub mod tower {
+ use super::{bind, bound, from_baggage, Bound};
+ use std::task::{Context, Poll};
+
+ #[derive(Clone, Copy, Debug, Default)]
+ pub struct Layer;
+
+ impl tower_layer::Layer for Layer {
+ type Service = Service;
+
+ fn layer(&self, inner: S) -> Service {
+ Service { inner }
+ }
+ }
+
+ #[derive(Clone, Debug)]
+ pub struct Service {
+ inner: S,
+ }
+
+ impl tower_service::Service> for Service
+ where
+ S: tower_service::Service>,
+ {
+ type Response = S::Response;
+ type Error = S::Error;
+ type Future = Bound;
+
+ fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> {
+ self.inner.poll_ready(cx)
+ }
+
+ fn call(&mut self, req: http::Request) -> Self::Future {
+ let f = from_baggage(req.headers().get("baggage").and_then(|v| v.to_str().ok()));
+ let inner = {
+ let _bound = bind(f.clone());
+ self.inner.call(req)
+ };
+ bound(f, inner)
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn baggage_round_trips_through_the_encoding_every_probe_shares() {
+ let f = Fixture { name: "it(\"a b\")".into(), file: "src/a,b.rs".into(), language: "rust".into(), kind: "unit".into() };
+ let back = from_baggage(Some(&format!("other=1,{};p=1", to_baggage(&f)))).unwrap();
+ assert_eq!(*back, f);
+ // go's query escaping writes a space as +
+ assert_eq!(from_baggage(Some("ccc.fixture=a+b,ccc.kind=unit")).unwrap().name, "a b");
+ assert!(from_baggage(Some("ccc.fixture=a")).is_none());
+ }
+}
diff --git a/probes/typescript/index.d.ts b/probes/typescript/index.d.ts
new file mode 100644
index 0000000..6ead65a
--- /dev/null
+++ b/probes/typescript/index.d.ts
@@ -0,0 +1,22 @@
+// types for ccc's node probe - the calls `ccc probe typescript` writes into a copy of the project
+
+// one test, named the way its runner selects it
+export interface Fixture {
+ name: string;
+ file: string;
+ language: string;
+ kind: string;
+}
+
+// counts one run of a function for the fixture this work runs for
+export function hit(file: string, name: string, line: number): void;
+// a test's callback, run as a fixture until it returns, resolves or calls done
+export function fixture(name: string, file: string, kind: string, fn: F): F;
+// runs fn as work for f
+export function bind(f: Fixture | undefined, fn: () => T): T;
+export function current(): Fixture | undefined;
+// the fixture as w3c baggage
+export function baggage(f: Fixture): string;
+export function fromBaggage(header: string | string[] | undefined): Fixture | undefined;
+// writes what fixtures ran in this process that no test of its own ended
+export function flush(): void;
diff --git a/probes/typescript/index.js b/probes/typescript/index.js
new file mode 100644
index 0000000..7290a35
--- /dev/null
+++ b/probes/typescript/index.js
@@ -0,0 +1,360 @@
+// ccc's node probe - records which functions ran on behalf of which test fixture and how many times, as ccc-observed/1 lines
+// off unless CCC_OBSERVED_DIR names a directory - `ccc probe typescript` puts the calls into a copy of the project and this package in its node_modules
+"use strict";
+
+const fs = require("node:fs");
+const path = require("node:path");
+const hooks = require("node:async_hooks");
+// the real process and clock - a test runner's sandbox hands its code copies of both
+const proc = require("node:process");
+const { performance } = require("node:perf_hooks");
+const commit = require("./commit.js");
+
+const SCHEMA = "ccc-observed/1";
+// how often a service writes what its fixtures ran - a process killed rather than stopped loses at most this much
+const FLUSH_MS = 2000;
+const dir = proc.env.CCC_OBSERVED_DIR || "";
+
+// one state per process however many copies of this module a test runner loads - kept on a core module every copy shares
+const KEY = Symbol.for("ccc.probe");
+const state =
+ hooks[KEY] ||
+ (hooks[KEY] = {
+ als: new hooks.AsyncLocalStorage(),
+ // what each fixture ran that is not written yet, by fixture key
+ ran: new Map(),
+ // this process's own tests still running - each writes its record when it ends
+ local: new Map(),
+ patched: new WeakSet(),
+ ticking: false,
+ });
+
+function key(f) {
+ return [f.name, f.file, f.language, f.kind].join("\0");
+}
+
+// the fixture this work runs for - bound by a test or a request, or this process's one running test
+function current() {
+ if (!dir) return undefined;
+ const f = state.als.getStore();
+ if (f) return f;
+ if (state.local.size === 1) return state.local.values().next().value.fixture;
+ return undefined;
+}
+
+// counts one run of a function for the fixture this work runs for
+function hit(file, name, line) {
+ if (!dir) return;
+ tick();
+ const f = current();
+ if (!f) return;
+ const k = key(f);
+ let entry = state.ran.get(k);
+ if (!entry) {
+ entry = { fixture: f, sites: new Map() };
+ state.ran.set(k, entry);
+ }
+ const s = file + "\0" + name + "\0" + line;
+ const site = entry.sites.get(s);
+ if (site) site.calls++;
+ else entry.sites.set(s, { file, name, line, calls: 1 });
+}
+
+// a test's callback, run as a fixture - a promise, a done callback or a plain return all end it
+function fixture(name, file, kind, fn) {
+ if (!dir || typeof fn !== "function") return fn;
+ const language = file.endsWith(".tsx") ? "tsx" : "typescript";
+ const wrapped = function (...args) {
+ const f = { name, file, language, kind };
+ const k = key(f);
+ const open = state.local.get(k) || { fixture: f, count: 0 };
+ open.count++;
+ state.local.set(k, open);
+ const start = performance.now();
+ let ended = false;
+ const end = () => {
+ if (ended) return;
+ ended = true;
+ finish(f, start);
+ };
+ return state.als.run(f, () => {
+ // a done-callback test ends when it calls done
+ if (fn.length > 0 && typeof args[0] === "function") {
+ const done = args[0];
+ const finished = Object.assign(function (...a) {
+ end();
+ return done.apply(this, a);
+ }, done);
+ try {
+ return fn.apply(this, [finished, ...args.slice(1)]);
+ } catch (e) {
+ end();
+ throw e;
+ }
+ }
+ let out;
+ try {
+ out = fn.apply(this, args);
+ } catch (e) {
+ end();
+ throw e;
+ }
+ if (out && typeof out.then === "function") {
+ return out.then(
+ (v) => {
+ end();
+ return v;
+ },
+ (e) => {
+ end();
+ throw e;
+ },
+ );
+ }
+ end();
+ return out;
+ });
+ };
+ Object.defineProperty(wrapped, "length", { value: fn.length });
+ Object.defineProperty(wrapped, "name", { value: fn.name });
+ return wrapped;
+}
+
+function finish(f, start) {
+ const k = key(f);
+ const open = state.local.get(k);
+ if (open && --open.count <= 0) state.local.delete(k);
+ const entry = state.ran.get(k);
+ state.ran.delete(k);
+ write(f, entry ? entry.sites : new Map(), Math.round(performance.now() - start));
+}
+
+// runs fn as work for f
+function bind(f, fn) {
+ return f ? state.als.run(f, fn) : fn();
+}
+
+// the fixture as w3c baggage - the same four members every ccc probe sends
+function baggage(f) {
+ return [
+ "ccc.fixture=" + encodeURIComponent(f.name),
+ "ccc.file=" + encodeURIComponent(f.file),
+ "ccc.lang=" + encodeURIComponent(f.language),
+ "ccc.kind=" + encodeURIComponent(f.kind),
+ ].join(",");
+}
+
+// the fixture a baggage header names, among whatever other members it carries
+function fromBaggage(header) {
+ if (Array.isArray(header)) header = header.join(",");
+ if (typeof header !== "string" || !header) return undefined;
+ const f = {};
+ const fields = { "ccc.fixture": "name", "ccc.file": "file", "ccc.lang": "language", "ccc.kind": "kind" };
+ for (const member of header.split(",")) {
+ const [pair] = member.split(";");
+ const at = pair.indexOf("=");
+ if (at < 0) continue;
+ const field = fields[pair.slice(0, at).trim()];
+ if (!field) continue;
+ try {
+ f[field] = decodeURIComponent(pair.slice(at + 1).trim().replace(/\+/g, " "));
+ } catch {
+ // a member that is not percent-encoded names nothing
+ }
+ }
+ return f.name && f.kind ? f : undefined;
+}
+
+// ccc's members in place of any earlier ones, beside whatever else the header carries
+function merge(header, ours) {
+ const kept = String(header || "")
+ .split(",")
+ .map((m) => m.trim())
+ .filter((m) => m && !m.startsWith("ccc."));
+ return [...kept, ours].join(",");
+}
+
+// every request an http or https server takes runs as work for the fixture its baggage names
+function patchServer(proto) {
+ if (!proto || state.patched.has(proto)) return;
+ state.patched.add(proto);
+ const emit = proto.emit;
+ proto.emit = function (event, req, ...rest) {
+ if (event === "request" && req && req.headers) {
+ const f = fromBaggage(req.headers.baggage);
+ if (f) return state.als.run(f, () => emit.call(this, event, req, ...rest));
+ }
+ return emit.call(this, event, req, ...rest);
+ };
+}
+
+// every request http or https sends carries the fixture it is sent for
+function patchClient(mod) {
+ if (!mod || state.patched.has(mod)) return;
+ state.patched.add(mod);
+ const request = mod.request;
+ mod.request = function (...args) {
+ const req = request.apply(this, args);
+ const f = current();
+ if (f && !req.headersSent) {
+ try {
+ req.setHeader("baggage", merge(req.getHeader("baggage"), baggage(f)));
+ } catch {
+ // headers already on the wire carry nothing more
+ }
+ }
+ return req;
+ };
+ mod.get = function (...args) {
+ const req = mod.request(...args);
+ req.end();
+ return req;
+ };
+}
+
+function patchFetch() {
+ const orig = globalThis.fetch;
+ if (typeof orig !== "function" || state.patched.has(orig)) return;
+ const patched = function (input, init) {
+ const f = current();
+ if (!f) return orig.call(this, input, init);
+ const from = init && init.headers ? init.headers : typeof Request !== "undefined" && input instanceof Request ? input.headers : undefined;
+ const headers = new Headers(from);
+ headers.set("baggage", merge(headers.get("baggage"), baggage(f)));
+ return orig.call(this, input, { ...init, headers });
+ };
+ state.patched.add(patched);
+ globalThis.fetch = patched;
+}
+
+// grpc-js - every call a server takes runs for the fixture its metadata names, every call a client makes carries its own
+function patchGrpc(grpc) {
+ if (!grpc || !grpc.Server || !grpc.Client || state.patched.has(grpc.Server.prototype)) return;
+ state.patched.add(grpc.Server.prototype);
+ const addService = grpc.Server.prototype.addService;
+ grpc.Server.prototype.addService = function (service, impl) {
+ const bound = Object.create(impl || null);
+ for (const [name, def] of Object.entries(service || {})) {
+ for (const n of [name, def && def.originalName]) {
+ const h = n && impl && impl[n];
+ if (typeof h !== "function") continue;
+ bound[n] = function (call, callback) {
+ const md = call && call.metadata;
+ const f = md && typeof md.get === "function" ? fromBaggage(md.get("baggage").map(String)) : undefined;
+ return bind(f, () => h.call(impl, call, callback));
+ };
+ }
+ }
+ return addService.call(this, service, bound);
+ };
+ const wrap = (method, fixed) => {
+ const orig = grpc.Client.prototype[method];
+ if (typeof orig !== "function") return;
+ grpc.Client.prototype[method] = function (...args) {
+ const f = current();
+ if (f) {
+ const at = args.findIndex((a, i) => i >= fixed && a instanceof grpc.Metadata);
+ const md = at >= 0 ? args[at].clone() : new grpc.Metadata();
+ md.set("baggage", merge(md.get("baggage").map(String).join(","), baggage(f)));
+ if (at >= 0) args[at] = md;
+ else args.splice(fixed, 0, md);
+ }
+ return orig.apply(this, args);
+ };
+ };
+ wrap("makeUnaryRequest", 4);
+ wrap("makeServerStreamRequest", 4);
+ wrap("makeClientStreamRequest", 3);
+ wrap("makeBidiStreamRequest", 3);
+}
+
+// what fixtures ran in this process that no test of its own ended - a service's share of a suite driven from outside
+function flush() {
+ if (!dir) return;
+ for (const [k, entry] of state.ran) {
+ if (state.local.has(k)) continue;
+ state.ran.delete(k);
+ write(entry.fixture, entry.sites, undefined);
+ }
+}
+
+function tick() {
+ if (state.ticking) return;
+ state.ticking = true;
+ const timer = setInterval(flush, FLUSH_MS);
+ if (timer.unref) timer.unref();
+ proc.on("exit", flush);
+ // a signal nobody else handles still writes the records before it ends the process
+ for (const signal of ["SIGTERM", "SIGINT"]) {
+ if (proc.listenerCount(signal) > 0) continue;
+ proc.once(signal, () => {
+ flush();
+ if (proc.listenerCount(signal) === 0) proc.kill(proc.pid, signal);
+ });
+ }
+}
+
+function service() {
+ return proc.env.CCC_SERVICE || path.basename(proc.argv[1] || proc.argv[0] || "node").replace(/\.[cm]?[jt]s$/, "");
+}
+
+// one ccc-observed/1 line - a fixture, and the functions it ran in this process with how many times each ran
+function write(f, sites, ms) {
+ if (sites.size === 0 && ms === undefined) return;
+ const byFile = new Map();
+ for (const s of sites.values()) {
+ if (!byFile.has(s.file)) byFile.set(s.file, []);
+ byFile.get(s.file).push({ name: s.name, line: s.line, calls: s.calls });
+ }
+ const files = [...byFile.keys()].sort().map((p) => ({ path: p, functions: byFile.get(p).sort((a, b) => a.line - b.line) }));
+ const svc = { name: service() };
+ const at = proc.env.CCC_COMMIT || commit;
+ if (at) svc.commit = at;
+ const record = {
+ schema: SCHEMA,
+ fixture: { name: f.name, kind: f.kind, file: f.file, language: f.language },
+ run: proc.env.CCC_RUN || "local",
+ service: svc,
+ files,
+ };
+ if (ms !== undefined) record.duration_ms = ms;
+ try {
+ fs.mkdirSync(dir, { recursive: true });
+ fs.appendFileSync(path.join(dir, `${service()}-${proc.pid}.jsonl`), JSON.stringify(record) + "\n");
+ } catch {
+ // a record that cannot be written is lost, never the run
+ }
+}
+
+if (dir) {
+ const http = require("node:http");
+ const https = require("node:https");
+ patchServer(http.Server && http.Server.prototype);
+ patchServer(https.Server && https.Server.prototype);
+ patchClient(http);
+ patchClient(https);
+ patchFetch();
+ try {
+ require("node:module").syncBuiltinESMExports();
+ } catch {
+ // older node has no esm view of the builtins to sync
+ }
+ try {
+ patchGrpc(require("@grpc/grpc-js"));
+ } catch {
+ // a project without grpc-js has nothing to patch
+ }
+ // grpc-js loaded from somewhere this package cannot see is patched as it loads
+ const Module = require("node:module");
+ if (typeof Module._load === "function" && !state.patched.has(Module)) {
+ state.patched.add(Module);
+ const load = Module._load;
+ Module._load = function (request) {
+ const exports = load.apply(this, arguments);
+ if (request === "@grpc/grpc-js") patchGrpc(exports);
+ return exports;
+ };
+ }
+}
+
+module.exports = { hit, fixture, bind, current, baggage, fromBaggage, flush };
diff --git a/probes/typescript/index.mjs b/probes/typescript/index.mjs
new file mode 100644
index 0000000..c3cf2d9
--- /dev/null
+++ b/probes/typescript/index.mjs
@@ -0,0 +1,5 @@
+// the esm face of ccc's node probe - re-exports the one commonjs instance, so esm and commonjs code count into the same records
+import probe from "./index.js";
+
+export const { hit, fixture, bind, current, baggage, fromBaggage, flush } = probe;
+export default probe;
diff --git a/src/changes.rs b/src/changes.rs
index 8068c5b..8530276 100644
--- a/src/changes.rs
+++ b/src/changes.rs
@@ -176,8 +176,11 @@ pub struct TestedBySite {
pub file: String,
pub line: usize,
pub language: String,
- // receiver-type | same-file | import | qualifier | name-only, weakest last
+ // observed | receiver-type | same-file | import | qualifier | name-only, weakest last
pub evidence: String,
+ // how many times a recorded run saw the test run this function - only on observed rows from a probe that counts
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub calls: Option,
}
#[derive(Debug, Serialize, Clone)]
@@ -604,6 +607,7 @@ pub(crate) fn assemble(
line: r.site.line,
language: r.site.language.to_string(),
evidence: r.evidence.label().to_string(),
+ calls: r.calls,
})
.collect();
let called_from: Vec = symbol_callers
diff --git a/src/coverage.rs b/src/coverage.rs
index 5776449..b3605bb 100644
--- a/src/coverage.rs
+++ b/src/coverage.rs
@@ -83,6 +83,8 @@ pub struct TestSite {
pub struct TestRef {
pub site: TestSite,
pub evidence: Evidence,
+ // how many times a recorded run saw the test run the definition - `None` for static evidence and uncounted records
+ pub calls: Option,
}
// A definition: (index into `caches`, index into that file's `funcs`).
@@ -133,7 +135,7 @@ impl CoverageIndex {
for (&def, fixtures) in &observed.ran {
let mut refs: Vec = fixtures
.iter()
- .map(|&f| TestRef { site: observed.fixtures[f].site(), evidence: Evidence::Observed })
+ .map(|&f| TestRef { site: observed.fixtures[f].site(), evidence: Evidence::Observed, calls: observed.calls(def, f) })
.collect();
refs.sort_by(|x, y| (&x.site.file, &x.site.name).cmp(&(&y.site.file, &y.site.name)));
self.observed.insert(def, refs);
@@ -458,7 +460,7 @@ pub(crate) fn build_over(
.map(|(def, sites)| {
let mut refs: Vec = sites
.into_iter()
- .map(|(site, evidence)| TestRef { site, evidence })
+ .map(|(site, evidence)| TestRef { site, evidence, calls: None })
.collect();
refs.sort_by(|x, y| {
(x.evidence, &x.site.file, &x.site.name).cmp(&(y.evidence, &y.site.file, &y.site.name))
diff --git a/src/edit.rs b/src/edit.rs
index 298b051..a8a0d83 100644
--- a/src/edit.rs
+++ b/src/edit.rs
@@ -34,6 +34,8 @@ const CCC_WRITABLE: &[&str] = &["map.json", "surface.json"];
const CALL_WRAPPERS: &[&str] = &["await_expression", "await", "try_expression", "parenthesized_expression"];
// the phrase an applied changeset is reported with - `prompts` reads it out of transcripts
const APPLIED_PHRASE: &str = "applied changeset ";
+// files one move carries - each is held as text until it is written
+const MAX_MOVE: usize = 5000;
// one place a `find` or `references` answer pointed at
#[derive(Debug, Clone)]
@@ -152,6 +154,8 @@ pub struct Changeset {
files: BTreeMap,
// identifiers renamed old to new - counted again once applied
renames: BTreeMap,
+ // files moved whole, new path to old - told as a move rather than a removal and a new file
+ moved: BTreeMap,
// why, one terse line per staging call that gave one - for the timeline
intents: Vec,
// each staging call's own change, in the order they came - so the
@@ -236,6 +240,7 @@ impl Store {
ops: Vec::new(),
files: BTreeMap::new(),
renames: BTreeMap::new(),
+ moved: BTreeMap::new(),
intents: Vec::new(),
calls: Vec::new(),
})
@@ -300,6 +305,14 @@ pub struct FileText {
}
impl Changeset {
+ // where a file this changeset moved came from - only while the old path stays removed and the new one holds text
+ fn origin(&self, rel: &str) -> Option<&str> {
+ let old = self.moved.get(rel)?;
+ let gone = self.files.get(old).is_some_and(|fc| fc.before.is_some() && fc.after.is_none());
+ let here = self.files.get(rel).is_some_and(|fc| fc.after.is_some());
+ (gone && here).then_some(old.as_str())
+ }
+
// the files this changeset moves, as one step; a revert runs the change
// backwards, so its before and after swap
fn activity(&self, status: &'static str) -> Activity {
@@ -346,6 +359,15 @@ impl Changeset {
// a project-relative path resolved inside the root - no `..`, no symlink on the way, never `.git`, only hand-edited `.ccc` files
pub fn confine(root: &Path, raw: &str) -> Result<(String, PathBuf), String> {
+ let (rel, abs) = confine_path(root, raw)?;
+ if abs.is_dir() {
+ return Err(format!("`{rel}` is a directory, not a file"));
+ }
+ Ok((rel, abs))
+}
+
+// the same checks for a path that may name a directory - what a move carries
+fn confine_path(root: &Path, raw: &str) -> Result<(String, PathBuf), String> {
let raw = raw.trim();
if raw.is_empty() || raw.contains('\0') {
return Err("an empty path names no file".into());
@@ -401,11 +423,7 @@ pub fn confine(root: &Path, raw: &str) -> Result<(String, PathBuf), String> {
Err(e) => return Err(format!("`{rel_str}`: {e}")),
}
}
- let abs = root.join(&clean);
- if abs.is_dir() {
- return Err(format!("`{rel_str}` is a directory, not a file"));
- }
- Ok((rel_str, abs))
+ Ok((rel_str, root.join(&clean)))
}
// file text
@@ -1700,6 +1718,116 @@ pub fn text(
})
}
+// moves a file, or a directory with every file under it, to a new path - each text goes over as it is and the old path is removed
+pub fn move_path(ctx: &Ctx, store: &mut Store, from: &str, to: &str, stage: &Stage) -> Result {
+ staged(ctx, store, stage, |ctx, _, cs| {
+ let (src, _) = confine_path(ctx.root, from)?;
+ let (dst, dst_abs) = confine_path(ctx.root, to)?;
+ if src == dst {
+ return Err(format!("`{src}` is already there - nothing to move"));
+ }
+ if dst.starts_with(&format!("{src}/")) {
+ return Err(format!("`{dst}` is inside `{src}` - nothing can move into itself"));
+ }
+ // a file moves alone, a directory with every file under it
+ let is_file = match cs.files.get(&src) {
+ Some(fc) => fc.after.is_some(),
+ None => ctx.root.join(&src).is_file(),
+ };
+ let olds: Vec = if is_file {
+ vec![src.clone()]
+ } else {
+ files_under(ctx.root, cs, &src)?.into_iter().collect()
+ };
+ if olds.is_empty() {
+ return Err(format!("`{src}` does not exist - nothing to move"));
+ }
+ if olds.len() > MAX_MOVE {
+ return Err(format!(
+ "`{src}` holds {} files - one move carries at most {MAX_MOVE}, so move its parts one by one",
+ olds.len()
+ ));
+ }
+ // the path it becomes must be free - an empty directory may stand there
+ let inside = format!("{dst}/");
+ let taken = match cs.files.get(&dst) {
+ Some(fc) => fc.after.is_some(),
+ None => dst_abs.is_file() || fs::read_dir(&dst_abs).is_ok_and(|mut d| d.next().is_some()),
+ } || cs.files.iter().any(|(rel, fc)| fc.after.is_some() && rel.starts_with(&inside));
+ if taken {
+ return Err(format!("`{dst}` already exists - `to` is the path `{src}` becomes, so it must be free"));
+ }
+ for old in &olds {
+ let (new, _) = confine(ctx.root, &format!("{dst}{}", &old[src.len()..]))?;
+ let text = match cs.files.get(old) {
+ Some(fc) => fc.after.clone(),
+ None => {
+ let (_, abs) = confine(ctx.root, old)?;
+ match fs::read_to_string(&abs) {
+ Ok(t) => Some(t),
+ Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
+ return Err(format!("`{old}` is not text - the edit tools carry text only, so nothing was moved"));
+ }
+ Err(e) => return Err(format!("reading {old}: {e}")),
+ }
+ }
+ };
+ if text_of(ctx.root, cs, &new)?.is_some() {
+ return Err(format!("`{new}` already exists - nothing was moved"));
+ }
+ put(ctx.root, cs, &new, text)?;
+ put(ctx.root, cs, old, None)?;
+ // a file moved twice keeps where it first came from
+ let first = cs.moved.remove(old).unwrap_or_else(|| old.clone());
+ if first != new {
+ cs.moved.insert(new, first);
+ }
+ }
+ let n = olds.len();
+ cs.ops.push(format!("move {src} -> {dst} ({n} file(s))"));
+ Ok(format!(
+ "# edit_move {src} -> {dst} ({n} file(s)) -> changeset `{}` (staged, nothing written yet)\n",
+ cs.id
+ ))
+ })
+}
+
+// every file under a directory as this changeset leaves it - a symlink inside is refused, since moving it could carry a write outside the project
+fn files_under(root: &Path, cs: &Changeset, dir: &str) -> Result, String> {
+ let mut out = BTreeSet::new();
+ let mut stack = vec![root.join(dir)];
+ while let Some(at) = stack.pop() {
+ let entries = match fs::read_dir(&at) {
+ Ok(e) => e,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
+ Err(e) => return Err(format!("reading {}: {e}", at.display())),
+ };
+ for entry in entries {
+ let entry = entry.map_err(|e| format!("reading {}: {e}", at.display()))?;
+ let path = entry.path();
+ let rel = path.strip_prefix(root).unwrap_or(&path).to_string_lossy().replace('\\', "/");
+ let kind = entry.file_type().map_err(|e| format!("reading {rel}: {e}"))?;
+ if kind.is_symlink() {
+ return Err(format!("`{rel}` is a symlink - a move could carry a write outside the project, so nothing was moved"));
+ }
+ if kind.is_dir() {
+ stack.push(path);
+ } else {
+ out.insert(rel);
+ }
+ }
+ }
+ let prefix = format!("{dir}/");
+ for (rel, fc) in cs.files.iter().filter(|(rel, _)| rel.starts_with(&prefix)) {
+ if fc.after.is_some() {
+ out.insert(rel.clone());
+ } else {
+ out.remove(rel);
+ }
+ }
+ Ok(out)
+}
+
// writing
// every file of a changeset written or none - each checked against what is expected there, swapped in through a temp file
@@ -1774,9 +1902,25 @@ fn write_all(root: &Path, cs: &Changeset, forward: bool) -> Result,
}
done.push((abs, *expect));
}
+ for (i, (_, abs, _, _)) in plan.iter().enumerate() {
+ if temps[i].is_none() {
+ prune_empty(root, abs);
+ }
+ }
Ok(plan.into_iter().map(|p| p.0).collect())
}
+// the directories above a removed file that it left empty, up to the root - `remove_dir` takes only an empty one
+fn prune_empty(root: &Path, file: &Path) {
+ let mut dir = file.parent();
+ while let Some(d) = dir.filter(|d| *d != root && d.starts_with(root)) {
+ if fs::remove_dir(d).is_err() {
+ break;
+ }
+ dir = d.parent();
+ }
+}
+
// writes a pending changeset, then checks every renamed name is gone
pub fn apply(root: &Path, store: &mut Store, id: &str) -> Result {
let id = id.trim();
@@ -1794,14 +1938,23 @@ pub fn apply(root: &Path, store: &mut Store, id: &str) -> Result = written.iter().filter_map(|rel| cs.origin(rel)).collect();
+ let listed: Vec<&String> = written.iter().filter(|rel| !vacated.contains(rel.as_str())).collect();
+ let mut out = format!("# edit_apply - {APPLIED_PHRASE}`{id}`\n{} file(s) written:\n", listed.len());
+ for rel in listed {
let fc = &cs.files[rel];
- let (adds, dels) = counts(fc.before.as_deref(), fc.after.as_deref());
- let what = match (&fc.before, &fc.after) {
- (None, _) => "new file".to_string(),
- (_, None) => "removed".to_string(),
- _ => format!("+{adds} -{dels}"),
+ let what = match (cs.origin(rel), &fc.before, &fc.after) {
+ (Some(old), _, _) => match counts(cs.files[old].before.as_deref(), fc.after.as_deref()) {
+ (0, 0) => format!("moved from {old}"),
+ (adds, dels) => format!("moved from {old}, +{adds} -{dels}"),
+ },
+ (None, None, _) => "new file".to_string(),
+ (None, _, None) => "removed".to_string(),
+ (None, before, after) => {
+ let (adds, dels) = counts(before.as_deref(), after.as_deref());
+ format!("+{adds} -{dels}")
+ }
};
out.push_str(&format!(" {rel} ({what})\n"));
}
@@ -1812,6 +1965,7 @@ pub fn apply(root: &Path, store: &mut Store, id: &str) -> Result Value {
.iter()
.filter(|(_, fc)| fc.before != fc.after)
.map(|(rel, fc)| {
- let written = added_lines(fc.before.as_deref(), fc.after.as_deref());
+ // a moved file wrote only what differs from the text it moved from
+ let origin = cs.origin(rel);
+ let since = match origin {
+ Some(old) => cs.files[old].before.as_deref(),
+ None => fc.before.as_deref(),
+ };
+ let written = added_lines(since, fc.after.as_deref());
let written: String = written.chars().take(LEDGER_TEXT_CAP).collect();
- json!({"path": rel, "written": written})
+ match origin {
+ Some(old) => json!({"path": rel, "written": written, "from": old}),
+ None => json!({"path": rel, "written": written}),
+ }
})
.collect();
json!({
@@ -2282,8 +2445,21 @@ pub(crate) fn unified(rel: &str, before: Option<&str>, after: Option<&str>) -> V
// the whole changeset as a diff, capped
fn preview(cs: &Changeset) -> String {
let mut lines: Vec = Vec::new();
- for (rel, fc) in cs.files.iter().filter(|(_, fc)| fc.before != fc.after) {
- lines.extend(unified(rel, fc.before.as_deref(), fc.after.as_deref()));
+ // a move is told once, at its new path - not as its whole text removed and written again
+ let vacated: BTreeSet<&str> = cs.files.keys().filter_map(|rel| cs.origin(rel)).collect();
+ for (rel, fc) in cs.files.iter().filter(|(rel, fc)| fc.before != fc.after && !vacated.contains(rel.as_str())) {
+ match cs.origin(rel) {
+ Some(old) => {
+ let was = cs.files[old].before.as_deref();
+ lines.push(format!("rename {old} -> {rel}"));
+ if was != fc.after.as_deref() {
+ let mut diff = unified(rel, was, fc.after.as_deref());
+ diff[0] = format!("--- a/{old}");
+ lines.extend(diff);
+ }
+ }
+ None => lines.extend(unified(rel, fc.before.as_deref(), fc.after.as_deref())),
+ }
}
if lines.is_empty() {
return "the changeset leaves every file as it is\n".into();
@@ -2432,6 +2608,7 @@ mod tests {
("c.rs".to_string(), file(Some("x\n"), Some("x\n"))),
]),
renames: BTreeMap::new(),
+ moved: BTreeMap::new(),
intents: Vec::new(),
calls: Vec::new(),
};
@@ -2492,6 +2669,84 @@ mod tests {
assert_eq!(reverted[1].files[0].after.as_deref(), Some("fn a() {}\n"));
}
+ // A directory moves as one change - told file by file as moves, written all
+ // at once, its emptied tree removed, and put back whole by a revert.
+ #[test]
+ fn a_directory_moves_whole_and_leaves_no_empty_directory_behind() {
+ let dir = Dir::new("move");
+ let root = &dir.0;
+ fs::create_dir_all(root.join("old/deep")).unwrap();
+ fs::write(root.join("old/a.rs"), "fn a() {}\n").unwrap();
+ fs::write(root.join("old/deep/b.md"), "# b\n").unwrap();
+ fs::write(root.join("old/.hidden"), "h\n").unwrap();
+ let caches = Vec::new();
+ let ctx = Ctx { root, caches: &caches };
+ let mut store = Store::new();
+ let stage = Stage { intent: Some("rename the project".into()), ..Default::default() };
+ let staged = move_path(&ctx, &mut store, "old", "new", &stage).unwrap();
+ // each file is one line, not its text removed and written again
+ assert!(staged.text.contains("rename old/a.rs -> new/a.rs"), "{}", staged.text);
+ assert!(!staged.text.contains("+fn a() {}"), "{}", staged.text);
+ let id = staged.activity[0].changeset.clone();
+
+ let done = apply(root, &mut store, &id).unwrap();
+ assert!(done.text.contains("3 file(s) written"), "{}", done.text);
+ assert!(done.text.contains("new/deep/b.md (moved from old/deep/b.md)"), "{}", done.text);
+ assert_eq!(fs::read_to_string(root.join("new/deep/b.md")).unwrap(), "# b\n");
+ assert_eq!(fs::read_to_string(root.join("new/.hidden")).unwrap(), "h\n");
+ assert!(!root.join("old").exists(), "the emptied tree goes with its files");
+ // the ledger keeps the move, not the moved text as new writing
+ let ledger = fs::read_to_string(root.join(".ccc").join(LEDGER_NAME)).unwrap();
+ assert!(ledger.contains(r#""from":"old/a.rs""#), "{ledger}");
+ assert!(!ledger.contains("fn a() {}"), "{ledger}");
+
+ revert(root, &mut store, &id).unwrap();
+ assert_eq!(fs::read_to_string(root.join("old/deep/b.md")).unwrap(), "# b\n");
+ assert!(!root.join("new").exists());
+ }
+
+ #[test]
+ fn a_move_refuses_a_taken_path_itself_and_a_file_that_is_not_text() {
+ let dir = Dir::new("move-refuse");
+ let root = &dir.0;
+ fs::create_dir_all(root.join("a")).unwrap();
+ fs::write(root.join("a/x.txt"), "x\n").unwrap();
+ fs::write(root.join("a/logo.png"), [0x89u8, 0x50, 0xff, 0xfe]).unwrap();
+ fs::write(root.join("b.txt"), "b\n").unwrap();
+ let caches = Vec::new();
+ let ctx = Ctx { root, caches: &caches };
+ let mut store = Store::new();
+ let stage = Stage { intent: Some("tidy".into()), ..Default::default() };
+ let mut try_move = |from: &str, to: &str| move_path(&ctx, &mut store, from, to, &stage).map(|o| o.text);
+ assert!(try_move("a", "a/inner").unwrap_err().contains("into itself"));
+ assert!(try_move("a/x.txt", "b.txt").unwrap_err().contains("already exists"));
+ assert!(try_move("missing", "c").unwrap_err().contains("does not exist"));
+ assert!(try_move("a", "c").unwrap_err().contains("not text"), "one file that is not text stops the whole move");
+ assert!(try_move("b.txt", "../out.txt").is_err());
+ assert!(try_move("b.txt", ".git/b.txt").is_err());
+ // an empty directory may stand where a directory lands
+ fs::remove_file(root.join("a/logo.png")).unwrap();
+ fs::create_dir_all(root.join("c")).unwrap();
+ assert!(try_move("a", "c").unwrap().contains("rename a/x.txt -> c/x.txt"));
+ assert!(root.join("a/x.txt").exists(), "staged, nothing written yet");
+ }
+
+ #[test]
+ fn removing_the_last_file_of_a_directory_removes_the_directory() {
+ let dir = Dir::new("prune");
+ let root = &dir.0;
+ fs::create_dir_all(root.join("docs/old")).unwrap();
+ fs::write(root.join("docs/old/a.md"), "a\n").unwrap();
+ fs::write(root.join("docs/keep.md"), "k\n").unwrap();
+ let caches = Vec::new();
+ let ctx = Ctx { root, caches: &caches };
+ let mut store = Store::new();
+ let stage = Stage { intent: Some("drop the old page".into()), apply: true, ..Default::default() };
+ text(&ctx, &mut store, "docs/old/a.md", None, None, false, true, &stage).unwrap();
+ assert!(!root.join("docs/old").exists());
+ assert!(root.join("docs/keep.md").exists(), "a directory that still holds a file stays");
+ }
+
#[test]
fn an_answer_names_the_changesets_it_applied() {
let text = format!("# edit_apply - {APPLIED_PHRASE}`c4-k2x9`\nnext");
diff --git a/src/insights.rs b/src/insights.rs
index 37b922a..0029eb9 100644
--- a/src/insights.rs
+++ b/src/insights.rs
@@ -1743,7 +1743,7 @@ fn test_triggers(report: &changes::ChangesReport, trig: &Triggered, targets: &Va
fn observed_triggers(report: &changes::ChangesReport, caches: &[FileCache], observed: &Observed) -> Value {
let by_path: BTreeMap =
caches.iter().enumerate().map(|(i, c)| (changes::path_str(&c.rel_path), i)).collect();
- let mut changed: Vec<(DefId, &str)> = Vec::new();
+ let mut changed: Vec<(DefId, &str, &str)> = Vec::new();
let mut unobserved = Vec::new();
// a changed test that a record names is a fixture to rerun whatever else is chosen
let mut rerun: BTreeSet = BTreeSet::new();
@@ -1755,12 +1755,12 @@ fn observed_triggers(report: &changes::ChangesReport, caches: &[FileCache], obse
rerun.extend(observed.fixture_at(&c.file, &c.function));
let is_test = changes::is_test_path(&c.file) || caches[fi].funcs[ki].test_ctx || changes::is_test_fn_name(&c.function);
if observed.ran.contains_key(&(fi, ki)) {
- changed.push(((fi, ki), c.function.as_str()));
+ changed.push(((fi, ki), c.function.as_str(), c.file.as_str()));
} else if !is_test {
unobserved.push(json!({"file": c.file, "function": c.function, "lines": c.lines}));
}
}
- let defs: Vec = changed.iter().map(|(d, _)| *d).collect();
+ let defs: Vec = changed.iter().map(|(d, _, _)| *d).collect();
let chosen = |sel: Selection| -> Value {
let picked: Vec = sel.fixtures.iter().chain(&rerun).copied().collect::>().into_iter().collect();
let mut by_lang: BTreeMap<&str, Vec> = BTreeMap::new();
@@ -1781,8 +1781,14 @@ fn observed_triggers(report: &changes::ChangesReport, caches: &[FileCache], obse
.iter()
.map(|&f| {
let fx = &observed.fixtures[f];
- let covers: Vec<&str> =
- changed.iter().filter(|(d, _)| observed.ran[d].contains(&f)).map(|(_, name)| *name).take(8).collect();
+ let covered: Vec<&(DefId, &str, &str)> =
+ changed.iter().filter(|(d, _, _)| observed.ran[d].contains(&f)).take(8).collect();
+ let covers: Vec<&str> = covered.iter().map(|(_, name, _)| *name).collect();
+ // how many times it ran each one - null from a probe that does not count
+ let ran: Vec = covered
+ .iter()
+ .map(|&&(d, name, file)| json!({"function": name, "file": file, "calls": observed.calls(d, f)}))
+ .collect();
json!({
"fixture": fx.name,
"file": fx.file,
@@ -1794,6 +1800,7 @@ fn observed_triggers(report: &changes::ChangesReport, caches: &[FileCache], obse
"runs": fx.runs,
"services": fx.services,
"covers": covers,
+ "ran": ran,
// the fixture is itself one of the changed functions
"changed": rerun.contains(&f),
})
@@ -3139,12 +3146,12 @@ pub fn helper() -> u64 { 1 }
);
let d = dir.path();
std::fs::create_dir_all(d.join(".ccc/observed")).unwrap();
- let record = |name: &str, kind: &str, ms: u64| {
- format!(r#"{{"schema":"ccc-observed/1","fixture":{{"name":"{name}","kind":"{kind}","file":"tests/test_api.py","language":"python"}},"run":"nightly-7","duration_ms":{ms},"files":[{{"path":"svc/fee.go","functions":[{{"name":"Fee"}}]}}]}}"#)
+ let record = |name: &str, kind: &str, ms: u64, calls: u64| {
+ format!(r#"{{"schema":"ccc-observed/1","fixture":{{"name":"{name}","kind":"{kind}","file":"tests/test_api.py","language":"python"}},"run":"nightly-7","duration_ms":{ms},"files":[{{"path":"svc/fee.go","functions":[{{"name":"Fee","calls":{calls}}}]}}]}}"#)
};
std::fs::write(
d.join(".ccc/observed/nightly.jsonl"),
- [record("test_refund", "integration", 2_000), record("test_soak", "smoke", 90_000)].join("\n"),
+ [record("test_refund", "integration", 2_000, 2), record("test_soak", "smoke", 90_000, 400)].join("\n"),
)
.unwrap();
run_git(d, &["init", "-q", "-b", "main"]);
@@ -3170,6 +3177,9 @@ pub fn helper() -> u64 { 1 }
assert_eq!(o["minimal"]["exact"], true);
let cmd = o["minimal"]["commands"][0]["command"].as_str().unwrap();
assert_eq!(cmd, "pytest -k \"test_refund\"");
+ // each fixture says how many times it ran the changed function
+ let soak = &o["safe"]["fixtures"][1];
+ assert_eq!(soak["ran"], json!([{"function": "Fee", "file": "svc/fee.go", "calls": 400}]), "{soak}");
// the change report names the fixtures as what tests `Fee`, strongest evidence first
let fee = v["changes"]["changed_functions"]
@@ -3181,6 +3191,7 @@ pub fn helper() -> u64 { 1 }
assert_eq!(fee["tested"], true);
assert_eq!(fee["tested_by_sites"][0]["evidence"], "observed");
assert_eq!(fee["tested_by_sites"][0]["test"], "test_refund");
+ assert_eq!(fee["tested_by_sites"][0]["calls"], 2);
// and no gap is reported for a function a run saw executed
assert!(v["test_triggers"]["add"].as_array().unwrap().is_empty(), "{}", v["test_triggers"]["add"]);
drop(dir);
diff --git a/src/lib.rs b/src/lib.rs
index 7566ba4..8aad88b 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -17,6 +17,7 @@ pub mod observed;
mod par;
pub mod parse_cache;
pub mod pr_summary;
+pub mod probe;
pub mod prompts;
pub mod render;
pub mod replay;
diff --git a/src/main.rs b/src/main.rs
index 7dc18a0..02855e4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -310,6 +310,30 @@ enum Command {
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
},
+ // a copy of the project with ccc's runtime probes in - every function counts its runs, every test names itself a
+ // fixture and every server and client carries that fixture, so one recorded run says which tests ran which code
+ Probe {
+ // go, rust, cpp (c++), typescript (ts) or python (py) - several, comma-separated, probe into one copy
+ #[arg(value_name = "LANG")]
+ langs: String,
+ #[arg(default_value = ".")]
+ path: PathBuf,
+ // where the instrumented copy goes - the project itself is never touched
+ #[arg(long, value_name = "DIR")]
+ out: PathBuf,
+ // GLOB=KIND - the fixture kind of the tests a glob matches, over what their path says (unit, contract,
+ // interop, integration, smoke, load) - repeatable
+ #[arg(long = "kind", value_name = "GLOB=KIND")]
+ kinds: Vec,
+ // write into an --out that already holds files
+ #[arg(long)]
+ force: bool,
+ // the directory records name files from - the nearest ccc project or repository above PATH by default
+ #[arg(long, value_name = "DIR")]
+ root: Option,
+ #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
+ format: OutputFormat,
+ },
// Replays of what agents did on a branch, kept under `refs/ccc/replay/`
// - beside the branches, never in them, so a merge cannot carry one to the
// default branch. Recording is opt-in per repository (`ccc replay enable`);
@@ -853,6 +877,16 @@ fn run() -> Result {
};
Ok(if gating > 0 { ExitCode::FAILURE } else { ExitCode::SUCCESS })
}
+ Command::Probe { langs, path, out, kinds, force, root, format } => {
+ let targets = langs.split(',').map(|l| parse_target(l.trim())).collect::, _>>().map_err(|e| anyhow!(e))?;
+ let opts = codecache::probe::Options { targets, src: path, out, kinds, force, root };
+ let report = codecache::probe::probe(&opts)?;
+ match format {
+ OutputFormat::Text => print!("{}", report.describe()),
+ OutputFormat::Json => println!("{}", serde_json::to_string(&report)?),
+ }
+ Ok(ExitCode::SUCCESS)
+ }
Command::Replay { action } => match action {
ReplayCommand::Save { path, branch, remote, no_push, default_branch, quiet } => {
let opts = codecache::replay::SaveOptions { branch, remote, push: !no_push, default_branch };
@@ -986,6 +1020,10 @@ fn run() -> Result {
}
}
+fn parse_target(s: &str) -> std::result::Result {
+ codecache::probe::Target::parse(s).ok_or_else(|| format!("'{s}' is not a language ccc probes - go, rust, cpp, typescript or python"))
+}
+
// shared by `prompts` and `pr-summary`
fn validate_agent(agent: Option<&str>) -> Result<()> {
match agent {
diff --git a/src/observed.rs b/src/observed.rs
index 7453e04..961872b 100644
--- a/src/observed.rs
+++ b/src/observed.rs
@@ -48,6 +48,8 @@ pub enum Kind {
Interop,
Integration,
Smoke,
+ // performance under load - the dearest run there is
+ Load,
}
impl Kind {
@@ -58,6 +60,7 @@ impl Kind {
Kind::Interop => "interop",
Kind::Integration => "integration",
Kind::Smoke => "smoke",
+ Kind::Load => "load",
}
}
@@ -69,6 +72,7 @@ impl Kind {
Kind::Interop => 3,
Kind::Integration => 4,
Kind::Smoke => 5,
+ Kind::Load => 6,
}
}
}
@@ -148,6 +152,9 @@ struct FuncIn {
owner: Option,
#[serde(default)]
line: Option,
+ // how many times it ran - absent from a probe that only notes that it ran
+ #[serde(default)]
+ calls: Option,
}
// one fixture across every record that names it
@@ -194,6 +201,8 @@ pub struct Observed {
pub fixtures: Vec,
// the fixtures that ran each definition
pub ran: BTreeMap>,
+ // how many times a fixture ran a definition - summed over the services of one run, the busiest run of several
+ pub calls: BTreeMap<(DefId, usize), u64>,
// record paths no file in the map holds - another repository's, usually
pub unmatched_files: BTreeSet,
// functions and line ranges a record names that the map no longer has
@@ -219,6 +228,11 @@ impl Observed {
self.fixtures.iter().position(|f| f.file.as_deref() == Some(file) && f.name == name)
}
+ // how many times `fixture` ran `def` - `None` when its records ran it without a count
+ pub fn calls(&self, def: DefId, fixture: usize) -> Option {
+ self.calls.get(&(def, fixture)).copied()
+ }
+
pub fn cost_of(&self, fixtures: &[usize]) -> u64 {
fixtures.iter().map(|&f| self.fixtures[f].cost()).sum()
}
@@ -428,6 +442,8 @@ fn tie(root: &Path, caches: &[FileCache], records: &[Record], out: &mut Observed
let mut located: BTreeMap<&str, Option> = BTreeMap::new();
let mut moved: BTreeMap<&str, Option>> = BTreeMap::new();
let mut keys: BTreeMap<(Option<&str>, Option<&str>, &str), usize> = BTreeMap::new();
+ // call counts by run - every service of one run adds to the same count, a rerun does not
+ let mut per_run: BTreeMap<(DefId, usize, Option<&str>), u64> = BTreeMap::new();
for r in records {
let fx = &r.fixture;
let key = (fx.language.as_deref(), fx.file.as_deref(), fx.name.as_str());
@@ -473,6 +489,11 @@ fn tie(root: &Path, caches: &[FileCache], records: &[Record], out: &mut Observed
for want in &file.functions {
let found = function(funcs, want, lines_hold);
out.unmatched += usize::from(found.is_empty());
+ if let Some(n) = want.calls {
+ for &k in &found {
+ *per_run.entry(((fi, k), at, r.run.as_deref())).or_default() += n;
+ }
+ }
hits.extend(found);
}
// line ranges stand in for functions only when the record names none
@@ -488,6 +509,10 @@ fn tie(root: &Path, caches: &[FileCache], records: &[Record], out: &mut Observed
}
}
}
+ for ((def, at, _), n) in per_run {
+ let most = out.calls.entry((def, at)).or_default();
+ *most = (*most).max(n);
+ }
}
// the definitions in one file a record's function names - by name, then owner, then nearest line
@@ -676,6 +701,30 @@ mod tests {
assert_eq!(f.cost(), 1200 * 4);
}
+ #[test]
+ fn call_counts_add_up_across_a_runs_services_and_a_rerun_does_not_double_them() {
+ let (dir, caches) = project("calls", &[("billing/server.go", BILLING)]);
+ let fee = def(&caches, "billing/server.go", "fee");
+ let rec = |name: &str, run: &str, service: &str, funcs: &str| {
+ format!(r#"{{"schema":"ccc-observed/1","fixture":{{"name":"{name}","kind":"integration","language":"go"}},"run":"{run}","service":{{"name":"{service}"}},"files":[{{"path":"billing/server.go","functions":[{funcs}]}}]}}"#)
+ };
+ let lines = [
+ // two processes of one run each ran fee for the same fixture
+ rec("TestRefund", "ci-1", "billing", r#"{"name":"fee","calls":3}"#),
+ rec("TestRefund", "ci-1", "ledger", r#"{"name":"fee","calls":4}"#),
+ // a quieter rerun of the same fixture
+ rec("TestRefund", "ci-2", "billing", r#"{"name":"fee","calls":5}"#),
+ // a probe that notes only that it ran
+ rec("TestFee", "ci-1", "billing", r#"{"name":"fee"}"#),
+ ];
+ let o = observed(&dir.0, &caches, &lines.iter().map(String::as_str).collect::>());
+ let refund = o.fixtures.iter().position(|f| f.name == "TestRefund").unwrap();
+ let unit = o.fixtures.iter().position(|f| f.name == "TestFee").unwrap();
+ assert_eq!(o.calls(fee, refund), Some(7));
+ assert_eq!(o.calls(fee, unit), None);
+ assert_eq!(o.ran[&fee], BTreeSet::from([refund, unit]), "a fixture with no count still ran it");
+ }
+
#[test]
fn safe_takes_every_fixture_that_ran_the_change_and_minimal_the_cheapest_cover() {
let (dir, caches) = project("select", &[("billing/server.go", BILLING)]);
diff --git a/src/probe.rs b/src/probe.rs
new file mode 100644
index 0000000..90af9ae
--- /dev/null
+++ b/src/probe.rs
@@ -0,0 +1,835 @@
+//! `ccc probe` - a copy of a project with ccc's runtime probes in: every function
+//! counts its runs, every test names itself a fixture, and servers and clients
+//! carry that fixture between services, so one recorded run yields the
+//! `ccc-observed/1` records `observed.rs` reads.
+
+use crate::changes::{is_test_fn_name, is_test_path, path_str};
+use crate::extract;
+use crate::languages::Language;
+use crate::model::{Func, Import};
+use crate::scan::{MAX_FILE_BYTES, SKIP_DIRS};
+use anyhow::{bail, Context, Result};
+use globset::{Glob, GlobMatcher};
+use serde::Serialize;
+use std::collections::BTreeSet;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use tree_sitter::{Node, Parser, Tree};
+
+mod cpp;
+mod go;
+mod python;
+mod rust;
+mod typescript;
+
+// where each language's runtime goes in the copy - hidden, so ccc never maps it as the project's own code
+pub const RUNTIME_DIR: &str = ".ccc-probe";
+
+// the fixture kinds `observed.rs` weighs
+const KINDS: &[&str] = &["unit", "contract", "interop", "integration", "smoke", "load"];
+
+// directories linked into the copy rather than copied - installed packages, never source
+const LINKED_DIRS: &[&str] = &[".venv", "venv"];
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Target {
+ Go,
+ Rust,
+ Cpp,
+ TypeScript,
+ Python,
+}
+
+impl Target {
+ pub fn parse(s: &str) -> Option {
+ Some(match s.to_ascii_lowercase().as_str() {
+ "go" | "golang" => Target::Go,
+ "rust" | "rs" => Target::Rust,
+ "c++" | "cpp" | "cxx" => Target::Cpp,
+ "typescript" | "ts" => Target::TypeScript,
+ "python" | "py" => Target::Python,
+ _ => return None,
+ })
+ }
+
+ pub fn label(self) -> &'static str {
+ match self {
+ Target::Go => "go",
+ Target::Rust => "rust",
+ Target::Cpp => "cpp",
+ Target::TypeScript => "typescript",
+ Target::Python => "python",
+ }
+ }
+
+ fn handles(self, lang: Language) -> bool {
+ matches!(
+ (self, lang),
+ (Target::Go, Language::Go)
+ | (Target::Rust, Language::Rust)
+ | (Target::Cpp, Language::Cpp)
+ | (Target::TypeScript, Language::TypeScript | Language::Tsx)
+ | (Target::Python, Language::Python)
+ )
+ }
+}
+
+pub struct Options {
+ // the languages to probe, each into the same copy
+ pub targets: Vec,
+ // the project, or the part of one, to instrument
+ pub src: PathBuf,
+ // where the instrumented copy goes
+ pub out: PathBuf,
+ // `GLOB=KIND` - the fixture kind of the tests a glob matches, over the default reading of their path
+ pub kinds: Vec,
+ // write into an `out` that already holds files
+ pub force: bool,
+ // the directory records name files from - the nearest ccc project or repository above `src` when left out
+ pub root: Option,
+}
+
+#[derive(Debug, Default, Serialize)]
+pub struct Report {
+ pub targets: Vec<&'static str>,
+ pub src: String,
+ pub out: String,
+ // the path records name files by - `src` relative to its repository
+ pub prefix: String,
+ pub commit: Option,
+ pub copied: usize,
+ // files the probes went into
+ pub files: usize,
+ // functions that count their runs
+ pub functions: usize,
+ // tests that name themselves fixtures
+ pub fixtures: usize,
+ // servers, clients and spawned work that carry a fixture
+ pub wrapped: usize,
+ // runtime files written into the copy
+ pub runtime: Vec,
+ // build files changed so the copy finds the runtime
+ pub edited: Vec,
+ // files left as they were, and why
+ pub skipped: Vec,
+ // how to record with the copy
+ pub next: Vec,
+ // how each language's suites run - folded into `next`
+ #[serde(skip)]
+ pub(crate) run: Vec,
+}
+
+// one file's probes - the text with them in, and what went in
+#[derive(Default)]
+pub(crate) struct Rewrite {
+ pub edits: Edits,
+ pub functions: usize,
+ pub fixtures: usize,
+ pub wrapped: usize,
+}
+
+// what one language does to the copy - read its build files, probe each source file, then write the runtime
+pub(crate) trait Prober {
+ fn prepare(&mut self, cx: &Cx) -> Result<()>;
+ fn rewrite(&mut self, cx: &Cx, file: &Source) -> Result;
+ fn finish(&mut self, cx: &Cx, report: &mut Report) -> Result<()>;
+}
+
+// what every language's rewrite reads about the run
+pub(crate) struct Cx {
+ pub target: Target,
+ pub out: PathBuf,
+ // prepended to a path inside the copy to name the file the way ccc maps it
+ pub prefix: String,
+ pub commit: Option,
+ // every regular file copied, by its path inside the copy
+ pub files: BTreeSet,
+ rules: Vec<(GlobMatcher, &'static str)>,
+}
+
+impl Cx {
+ // the path a record names a file by - the repository's path, not the copy's
+ pub fn record_path(&self, rel: &str) -> String {
+ format!("{}{rel}", self.prefix)
+ }
+
+ // the fixture kind of a test file - a `--kind` glob first, then what its path says
+ pub fn kind(&self, rel: &str) -> &'static str {
+ let path = self.record_path(rel);
+ self.rules
+ .iter()
+ .find(|(glob, _)| glob.is_match(rel) || glob.is_match(&path))
+ .map(|(_, kind)| *kind)
+ .unwrap_or_else(|| default_kind(&path, self.target))
+ }
+
+ pub fn write(&self, rel: &str, text: &str) -> Result<()> {
+ let to = self.out.join(rel);
+ if let Some(dir) = to.parent() {
+ std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
+ }
+ std::fs::write(&to, text).with_context(|| format!("writing {}", to.display()))
+ }
+
+ pub fn read(&self, rel: &str) -> Option {
+ std::fs::read_to_string(self.out.join(rel)).ok()
+ }
+}
+
+// one source file as the rewrite sees it
+pub(crate) struct Source {
+ // its path inside the copy
+ pub rel: String,
+ // its path as ccc maps it
+ pub path: String,
+ pub lang: Language,
+ pub text: String,
+ pub tree: Tree,
+ pub funcs: Vec,
+ pub imports: Vec,
+ // a test file - its functions are tests or their helpers, so none counts its runs
+ pub test: bool,
+ pub kind: &'static str,
+}
+
+impl Source {
+ pub fn root(&self) -> Node<'_> {
+ self.tree.root_node()
+ }
+
+ pub fn text_of(&self, node: Node) -> &str {
+ &self.text[node.byte_range()]
+ }
+
+ // every function the map names, with its node - the map's own name and owner are what the records use
+ pub fn defs(&self) -> Vec> {
+ let mut nodes = Vec::new();
+ let kinds = self.lang.func_kinds();
+ collect(self.root(), &mut |n| {
+ if kinds.contains(&n.kind()) {
+ nodes.push(n);
+ }
+ });
+ let starts = line_starts(&self.text);
+ let offset = |line: usize, col: usize| starts.get(line.saturating_sub(1)).map_or(0, |s| s + col.saturating_sub(1));
+ let mut out = Vec::new();
+ let mut taken = BTreeSet::new();
+ for node in nodes {
+ let (start, end) = (node.start_position().row + 1, node.end_position().row + 1);
+ let at = node.start_byte();
+ // the name nearest the node's start - just inside it for a declaration, just before it for a bound lambda
+ let best = (0..self.funcs.len())
+ .filter(|&k| self.funcs[k].start_line == start && self.funcs[k].end_line == end && !taken.contains(&k))
+ .min_by_key(|&k| offset(self.funcs[k].line, self.funcs[k].col).abs_diff(at));
+ if let Some(k) = best {
+ taken.insert(k);
+ out.push(Def { node, func: &self.funcs[k] });
+ }
+ }
+ out
+ }
+
+ // the name a module path is bound to in this file, if it is imported
+ pub fn import_name(&self, module: &str) -> Option {
+ let import = self.imports.iter().find(|i| i.module == module)?;
+ Some(import.names.first().cloned().unwrap_or_else(|| module.rsplit('/').next().unwrap_or(module).to_string()))
+ }
+}
+
+pub(crate) struct Def<'a> {
+ pub node: Node<'a>,
+ pub func: &'a Func,
+}
+
+impl Def<'_> {
+ // `Ownername` - the qualified name `observed.rs` splits back into owner and name
+ pub fn qualified(&self, sep: &str) -> String {
+ match &self.func.owner {
+ Some(owner) if !owner.is_empty() => format!("{owner}{sep}{}", self.func.name),
+ _ => self.func.name.clone(),
+ }
+ }
+
+ pub fn is_test(&self) -> bool {
+ self.func.test_ctx || is_test_fn_name(&self.func.name)
+ }
+}
+
+// text to put in at offsets of the source - only a replacement cuts anything, so every comment stays where it was
+#[derive(Default)]
+pub(crate) struct Edits(Vec<(usize, i64, String, usize)>);
+
+impl Edits {
+ pub fn insert(&mut self, at: usize, text: impl Into) {
+ let n = self.0.len() as i64;
+ self.0.push((at, n, text.into(), at));
+ }
+
+ // closing text - at one offset it goes before any opening text, the last made first, so wraps nest
+ pub fn close(&mut self, at: usize, text: impl Into) {
+ let n = self.0.len() as i64;
+ self.0.push((at, -n - 1, text.into(), at));
+ }
+
+ // a node's text swapped for another - a callee renamed, never a span another edit lands inside
+ pub fn replace(&mut self, node: Node, text: impl Into) {
+ let n = self.0.len() as i64;
+ self.0.push((node.start_byte(), n, text.into(), node.end_byte()));
+ }
+
+ // `open` before a node and `close` after it
+ pub fn wrap(&mut self, node: Node, open: impl Into, close: impl Into) {
+ self.insert(node.start_byte(), open);
+ self.close(node.end_byte(), close);
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ pub fn apply(mut self, src: &str) -> String {
+ self.0.sort_by_key(|(at, order, _, _)| (*at, *order));
+ let mut out = String::with_capacity(src.len() + self.0.iter().map(|(_, _, t, _)| t.len()).sum::());
+ let mut last = 0;
+ for (at, _, text, end) in &self.0 {
+ if *at >= last {
+ out.push_str(&src[last..*at]);
+ last = *at;
+ }
+ out.push_str(text);
+ last = last.max(*end);
+ }
+ out.push_str(&src[last..]);
+ out
+ }
+}
+
+// a double-quoted string literal every target language reads the same way
+pub(crate) fn quote(s: &str) -> String {
+ let mut out = String::with_capacity(s.len() + 2);
+ out.push('"');
+ for c in s.chars() {
+ match c {
+ '\\' => out.push_str("\\\\"),
+ '"' => out.push_str("\\\""),
+ c if c.is_control() => out.push(' '),
+ c => out.push(c),
+ }
+ }
+ out.push('"');
+ out
+}
+
+// `to` relative to the directory `from` - both paths inside the copy, `/`-separated
+pub(crate) fn relative(from: &str, to: &str) -> String {
+ let from: Vec<&str> = from.split('/').filter(|s| !s.is_empty() && *s != ".").collect();
+ let to: Vec<&str> = to.split('/').filter(|s| !s.is_empty() && *s != ".").collect();
+ let common = from.iter().zip(&to).take_while(|(a, b)| a == b).count();
+ let mut parts: Vec<&str> = vec![".."; from.len() - common];
+ parts.extend(&to[common..]);
+ if parts.is_empty() {
+ ".".to_string()
+ } else {
+ parts.join("/")
+ }
+}
+
+// the directory a path inside the copy sits in - empty at the top
+pub(crate) fn dir_of(rel: &str) -> &str {
+ rel.rsplit_once('/').map_or("", |(d, _)| d)
+}
+
+// every node under `node`, depth first
+pub(crate) fn collect<'t>(node: Node<'t>, f: &mut impl FnMut(Node<'t>)) {
+ f(node);
+ let mut cursor = node.walk();
+ for child in node.children(&mut cursor) {
+ collect(child, f);
+ }
+}
+
+// the first direct child of a kind
+pub(crate) fn child_of_kind<'t>(node: Node<'t>, kind: &str) -> Option> {
+ let mut cursor = node.walk();
+ let found = node.children(&mut cursor).find(|c| c.kind() == kind);
+ found
+}
+
+pub(crate) fn named_children(node: Node) -> Vec {
+ let mut cursor = node.walk();
+ node.named_children(&mut cursor).filter(|c| !c.is_extra()).collect()
+}
+
+fn line_starts(text: &str) -> Vec {
+ std::iter::once(0).chain(text.match_indices('\n').map(|(i, _)| i + 1)).collect()
+}
+
+// the kind a test's path says it is - its own name first, then its directories innermost out
+pub(crate) fn default_kind(path: &str, target: Target) -> &'static str {
+ let lower = path.to_ascii_lowercase();
+ let parts: Vec<&str> = lower.split('/').collect();
+ let (dirs, file) = parts.split_at(parts.len() - 1);
+ for word in file[0].split(['.', '_', '-']) {
+ let kind = match word {
+ "contract" | "contracts" | "pact" => "contract",
+ "interop" => "interop",
+ "integration" | "e2e" => "integration",
+ "smoke" => "smoke",
+ _ => continue,
+ };
+ return kind;
+ }
+ for dir in dirs.iter().rev() {
+ if *dir == "it" {
+ return "integration";
+ }
+ for word in dir.split(['.', '_', '-']) {
+ let kind = match word {
+ "contract" | "contracts" | "pact" | "pacts" => "contract",
+ "interop" => "interop",
+ "smoke" => "smoke",
+ "load" | "perf" | "performance" | "stress" | "soak" | "benchmark" | "benchmarks" | "bench" | "benches" => "load",
+ "integration" | "integrations" | "e2e" | "end2end" | "acceptance" | "functional" => "integration",
+ _ => continue,
+ };
+ return kind;
+ }
+ }
+ // a crate's `tests/` directory is where rust keeps its integration tests
+ if target == Target::Rust && dirs.contains(&"tests") {
+ return "integration";
+ }
+ "unit"
+}
+
+fn parse_rules(kinds: &[String]) -> Result> {
+ kinds
+ .iter()
+ .map(|rule| {
+ let (glob, kind) = rule.rsplit_once('=').with_context(|| format!("--kind wants GLOB=KIND, got '{rule}'"))?;
+ let kind = KINDS
+ .iter()
+ .find(|k| **k == kind.trim())
+ .with_context(|| format!("--kind '{rule}': the kind must be one of {}", KINDS.join(", ")))?;
+ let glob = Glob::new(glob.trim()).with_context(|| format!("--kind '{rule}'"))?.compile_matcher();
+ Ok((glob, *kind))
+ })
+ .collect()
+}
+
+// `dir`'s path inside the project ccc maps it from - `root` when given, else the nearest ccc project or repository above it
+fn repo_prefix(dir: &Path, root: Option<&Path>) -> Result<(String, Option)> {
+ let found = match root {
+ Some(root) => Some(root.canonicalize().with_context(|| format!("reading --root {}", root.display()))?),
+ None => dir.ancestors().find(|a| a.join(".ccc").is_dir() || a.join(".git").exists()).map(Path::to_path_buf),
+ };
+ let Some(root) = found else {
+ return Ok((String::new(), None));
+ };
+ if !dir.starts_with(&root) {
+ bail!("--root {} is not above {}", root.display(), dir.display());
+ }
+ let prefix = match dir.strip_prefix(&root) {
+ Ok(rel) if !rel.as_os_str().is_empty() => format!("{}/", path_str(rel)),
+ _ => String::new(),
+ };
+ Ok((prefix, Some(root)))
+}
+
+// the commit the copy is made from - only where records name files from the repository's own root, as `git diff` names them
+fn root_commit(root: Option<&Path>) -> Option {
+ root.filter(|r| r.join(".git").exists()).and_then(head_commit)
+}
+
+fn head_commit(dir: &Path) -> Option {
+ let out = Command::new("git").args(["rev-parse", "HEAD"]).current_dir(dir).output().ok()?;
+ let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
+ (out.status.success() && id.bytes().all(|b| b.is_ascii_hexdigit()) && !id.is_empty()).then_some(id)
+}
+
+// a file the map holds - outside every directory ccc's scan skips, and small enough to parse
+fn mapped(rel: &str, len: u64) -> bool {
+ len <= MAX_FILE_BYTES
+ && dir_of(rel).split('/').filter(|s| !s.is_empty()).all(|d| !d.starts_with('.') && !SKIP_DIRS.contains(&d))
+}
+
+// generated code - its functions are the generator's, so no record should name them
+fn generated(rel: &str, text: &str) -> bool {
+ const SUFFIXES: &[&str] = &[
+ ".pb.go", "_pb2.py", "_pb2_grpc.py", ".pb.cc", ".pb.h", ".pb.ts", "_pb.ts", "_grpc_pb.ts", ".pb.rs",
+ ];
+ if SUFFIXES.iter().any(|s| rel.ends_with(s)) {
+ return true;
+ }
+ let head = &text[..text.len().min(2048)];
+ (head.contains("Code generated") && head.contains("DO NOT EDIT")) || head.contains("@generated")
+}
+
+// copy `src` into `out` the way git sees it - ignored files left behind, installed packages linked rather than copied
+fn copy_tree(src: &Path, out: &Path, files: &mut BTreeSet) -> Result<()> {
+ let out_owned = out.to_path_buf();
+ let walker = ignore::WalkBuilder::new(src)
+ .hidden(false)
+ .parents(true)
+ .ignore(false)
+ .git_global(false)
+ .git_ignore(true)
+ .git_exclude(true)
+ .require_git(false)
+ .filter_entry(move |e| {
+ let name = e.file_name().to_string_lossy();
+ let dir = e.file_type().is_some_and(|t| t.is_dir());
+ // build output a repository without a .gitignore still holds - a fresh build in the copy remakes it
+ let built = name == "__pycache__" || (name == "target" && e.path().parent().is_some_and(|p| p.join("Cargo.toml").is_file()));
+ !(dir && (built || name == ".git" || name == "node_modules" || LINKED_DIRS.contains(&name.as_ref()) || e.path() == out_owned))
+ })
+ .build();
+ for entry in walker {
+ let entry = entry?;
+ let path = entry.path();
+ let Ok(rel) = path.strip_prefix(src) else { continue };
+ let rel = path_str(rel);
+ let to = out.join(&rel);
+ let Some(kind) = entry.file_type() else { continue };
+ if kind.is_dir() {
+ std::fs::create_dir_all(&to).with_context(|| format!("creating {}", to.display()))?;
+ link_installed(path, &to)?;
+ } else if kind.is_symlink() {
+ let target = std::fs::read_link(path)?;
+ let _ = std::fs::remove_file(&to);
+ symlink(&target, &to)?;
+ } else if kind.is_file() {
+ std::fs::copy(path, &to).with_context(|| format!("copying {}", path.display()))?;
+ files.insert(rel);
+ }
+ }
+ Ok(())
+}
+
+// a directory's installed packages, reachable from the copy - node_modules entry by entry so the copy can add its own
+fn link_installed(dir: &Path, to: &Path) -> Result<()> {
+ for name in LINKED_DIRS {
+ let from = dir.join(name);
+ if from.is_dir() && !to.join(name).exists() {
+ symlink(&from, &to.join(name))?;
+ }
+ }
+ let modules = dir.join("node_modules");
+ if !modules.is_dir() {
+ return Ok(());
+ }
+ let into = to.join("node_modules");
+ std::fs::create_dir_all(&into)?;
+ for entry in std::fs::read_dir(&modules)? {
+ let entry = entry?;
+ let link = into.join(entry.file_name());
+ if !link.exists() && link.symlink_metadata().is_err() {
+ symlink(&entry.path(), &link)?;
+ }
+ }
+ Ok(())
+}
+
+#[cfg(unix)]
+fn symlink(target: &Path, link: &Path) -> Result<()> {
+ std::os::unix::fs::symlink(target, link).with_context(|| format!("linking {}", link.display()))
+}
+
+#[cfg(not(unix))]
+fn symlink(target: &Path, link: &Path) -> Result<()> {
+ if target.is_dir() {
+ return Ok(());
+ }
+ std::fs::copy(target, link).map(|_| ()).with_context(|| format!("copying {}", target.display()))
+}
+
+// an error node somewhere below - a token the parser only had to assume, like `TEST_CASE("x") {`'s missing `;`, is not one
+fn broken(node: Node) -> bool {
+ if !node.has_error() {
+ return false;
+ }
+ if node.is_error() {
+ return true;
+ }
+ let mut cursor = node.walk();
+ let found = node.children(&mut cursor).any(broken);
+ found
+}
+
+// parse one copied file for the rewrite - `None` when there is nothing a probe could go into
+fn source(cx: &Cx, rel: &str, lang: Language) -> Result> {
+ let Ok(text) = std::fs::read_to_string(cx.out.join(rel)) else {
+ return Ok(None);
+ };
+ if generated(rel, &text) {
+ return Ok(None);
+ }
+ let Some(extracted) = extract::extract(lang, &text) else {
+ bail!("withdrawn by `ccc:skip`, or not parseable");
+ };
+ let mut parser = Parser::new();
+ parser.set_language(&lang.ts_language())?;
+ let tree = parser.parse(&text, None).context("not parseable")?;
+ if broken(tree.root_node()) {
+ bail!("has syntax the parser cannot read, so no probe goes in");
+ }
+ let path = cx.record_path(rel);
+ Ok(Some(Source {
+ test: is_test_path(&path),
+ kind: cx.kind(rel),
+ rel: rel.to_string(),
+ path,
+ lang,
+ text,
+ tree,
+ funcs: extracted.funcs,
+ imports: extracted.imports,
+ }))
+}
+
+pub fn probe(opts: &Options) -> Result {
+ let src = opts.src.canonicalize().with_context(|| format!("reading {}", opts.src.display()))?;
+ if !src.is_dir() {
+ bail!("{} is not a directory", src.display());
+ }
+ let out = if opts.out.is_absolute() { opts.out.clone() } else { std::env::current_dir()?.join(&opts.out) };
+ if out.exists() {
+ let out = out.canonicalize()?;
+ if out == src || src.starts_with(&out) {
+ bail!("--out must be somewhere other than {} and the directories above it", src.display());
+ }
+ if std::fs::read_dir(&out)?.next().is_some() && !opts.force {
+ bail!("{} already holds files - pass --force to write over them", out.display());
+ }
+ }
+ std::fs::create_dir_all(&out).with_context(|| format!("creating {}", out.display()))?;
+ let out = out.canonicalize()?;
+
+ if opts.targets.is_empty() {
+ bail!("name a language to probe - go, rust, cpp, typescript or python");
+ }
+ let (prefix, root) = repo_prefix(&src, opts.root.as_deref())?;
+ let mut cx = Cx {
+ target: opts.targets[0],
+ out: out.clone(),
+ prefix,
+ commit: root_commit(root.as_deref()),
+ files: BTreeSet::new(),
+ rules: parse_rules(&opts.kinds)?,
+ };
+ let mut files = BTreeSet::new();
+ copy_tree(&src, &out, &mut files)?;
+ cx.files = files;
+
+ let mut report = Report {
+ targets: opts.targets.iter().map(|t| t.label()).collect(),
+ src: path_str(&src),
+ out: path_str(&out),
+ prefix: cx.prefix.clone(),
+ commit: cx.commit.clone(),
+ copied: cx.files.len(),
+ ..Report::default()
+ };
+ for &target in &opts.targets {
+ cx.target = target;
+ probe_target(&cx, &mut report)?;
+ }
+ report.next = record_steps(&cx, &report.run);
+ Ok(report)
+}
+
+// one language's probes into the copy - its build files read, its sources rewritten, its runtime written
+fn probe_target(cx: &Cx, report: &mut Report) -> Result<()> {
+ let mut prober: Box = match cx.target {
+ Target::Go => Box::new(go::Go::default()),
+ Target::Rust => Box::new(rust::Rust::default()),
+ Target::Cpp => Box::new(cpp::Cpp::default()),
+ Target::TypeScript => Box::new(typescript::TypeScript),
+ Target::Python => Box::new(python::Python::default()),
+ };
+ prober.prepare(cx)?;
+ let candidates: Vec<(String, Language)> = cx
+ .files
+ .iter()
+ .filter_map(|rel| {
+ let lang = Language::from_path(Path::new(rel)).filter(|l| cx.target.handles(*l))?;
+ let len = std::fs::metadata(cx.out.join(rel)).map(|m| m.len()).unwrap_or(u64::MAX);
+ mapped(rel, len).then(|| (rel.clone(), lang))
+ })
+ .collect();
+ for (rel, lang) in candidates {
+ let file = match source(cx, &rel, lang) {
+ Ok(Some(file)) => file,
+ Ok(None) => continue,
+ Err(e) => {
+ report.skipped.push(format!("{rel}: {e:#}"));
+ continue;
+ }
+ };
+ match prober.rewrite(cx, &file) {
+ Ok(rewrite) if !rewrite.edits.is_empty() => {
+ report.functions += rewrite.functions;
+ report.fixtures += rewrite.fixtures;
+ report.wrapped += rewrite.wrapped;
+ report.files += 1;
+ cx.write(&rel, &rewrite.edits.apply(&file.text))?;
+ }
+ Ok(_) => {}
+ Err(e) => report.skipped.push(format!("{rel}: {e:#}")),
+ }
+ }
+ prober.finish(cx, report)
+}
+
+impl Report {
+ pub fn describe(&self) -> String {
+ let mut out = format!(
+ "ccc probe {}: {} functions count their runs and {} tests name themselves fixtures, in {} files - {} servers, clients and spawns carry the fixture\ncopied {} files from {} into {}\n",
+ self.targets.join(","),
+ self.functions,
+ self.fixtures,
+ self.files,
+ self.wrapped,
+ self.copied,
+ self.src,
+ self.out
+ );
+ if !self.prefix.is_empty() {
+ out.push_str(&format!("records name files from the repository root - under {}\n", self.prefix.trim_end_matches('/')));
+ }
+ for (title, rows) in [("runtime", &self.runtime), ("edited", &self.edited), ("left as they were", &self.skipped)] {
+ if rows.is_empty() {
+ continue;
+ }
+ out.push_str(&format!("{title}:\n"));
+ for row in rows {
+ out.push_str(&format!(" {row}\n"));
+ }
+ }
+ if !self.next.is_empty() {
+ out.push_str("to record:\n");
+ for row in &self.next {
+ out.push_str(&format!(" {row}\n"));
+ }
+ }
+ out
+ }
+}
+
+// the shared "how to record" lines - each language adds how its own suites run
+pub(crate) fn record_steps(cx: &Cx, run: &[String]) -> Vec {
+ let out = path_str(&cx.out);
+ let mut steps = vec![format!("export CCC_OBSERVED_DIR=$PWD/observed # nothing is recorded without it")];
+ steps.extend(run.iter().map(|r| format!("(cd {out} && {r})")));
+ steps.push("start each instrumented service with CCC_SERVICE= and let it exit cleanly, or give it two seconds to flush".to_string());
+ steps.push("cat observed/*.jsonl > baseline.jsonl - then point `observed` in .ccc/map.json at it, or pass --observed".to_string());
+ steps
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_path_names_its_kind_by_its_own_name_then_its_innermost_directory() {
+ assert_eq!(default_kind("services/storage/storage_test.go", Target::Go), "unit");
+ assert_eq!(default_kind("integration/stack_test.go", Target::Go), "integration");
+ assert_eq!(default_kind("qa/contract/test_shapes.py", Target::Python), "contract");
+ assert_eq!(default_kind("qa/load/test_throughput.py", Target::Python), "load");
+ assert_eq!(default_kind("tests/load_tests/checkout.spec.ts", Target::TypeScript), "load");
+ assert_eq!(default_kind("web/checkout.e2e.spec.ts", Target::TypeScript), "integration");
+ assert_eq!(default_kind("src/billing/smoke_test.go", Target::Go), "smoke");
+ // `load` names a kind only as a directory - a file about loading config is a unit test
+ assert_eq!(default_kind("config/load_config_test.go", Target::Go), "unit");
+ assert_eq!(default_kind("payload/encode_test.go", Target::Go), "unit");
+ assert_eq!(default_kind("billing/tests/refunds.rs", Target::Rust), "integration");
+ assert_eq!(default_kind("billing/tests/refunds.py", Target::Python), "unit");
+ }
+
+ #[test]
+ fn the_prefix_names_the_directory_inside_the_nearest_project() {
+ let dir = std::env::temp_dir().join(format!("ccc-probe-root-{}", std::process::id()));
+ std::fs::create_dir_all(dir.join("svc/billing/.ccc")).unwrap();
+ std::fs::create_dir_all(dir.join("svc/billing/api")).unwrap();
+ let api = dir.join("svc/billing/api").canonicalize().unwrap();
+ // the nearest ccc project wins over anything further up
+ assert_eq!(repo_prefix(&api, None).unwrap().0, "api/");
+ assert_eq!(repo_prefix(&api, Some(&dir)).unwrap().0, "svc/billing/api/");
+ // no repository at that root, so no commit to stamp
+ assert_eq!(root_commit(repo_prefix(&api, None).unwrap().1.as_deref()), None);
+ assert!(repo_prefix(&dir, Some(&api)).is_err());
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn a_kind_rule_overrides_the_path() {
+ let rules = parse_rules(&["qa/**=smoke".to_string()]).unwrap();
+ let cx = Cx { target: Target::Python, out: PathBuf::new(), prefix: String::new(), commit: None, files: BTreeSet::new(), rules };
+ assert_eq!(cx.kind("qa/integration/test_flow.py"), "smoke");
+ assert_eq!(cx.kind("tests/integration/test_flow.py"), "integration");
+ assert!(parse_rules(&["qa/**=nightly".to_string()]).is_err());
+ assert!(parse_rules(&["qa/**".to_string()]).is_err());
+ }
+
+ #[test]
+ fn edits_nest_where_wraps_share_an_offset() {
+ let mut edits = Edits::default();
+ edits.insert(0, "outer(");
+ edits.insert(0, "inner(");
+ edits.close(1, ")");
+ edits.close(1, "]");
+ edits.insert(1, "+");
+ assert_eq!(edits.apply("x"), "outer(inner(x])+");
+ }
+
+ #[test]
+ fn a_replacement_swaps_its_node_and_leaves_the_rest() {
+ let src = "grpc::CreateChannel(a, b);";
+ let mut parser = Parser::new();
+ parser.set_language(&Language::Cpp.ts_language()).unwrap();
+ let tree = parser.parse(src, None).unwrap();
+ let mut callee = None;
+ collect(tree.root_node(), &mut |n| {
+ if n.kind() == "qualified_identifier" && callee.is_none() {
+ callee = Some(n);
+ }
+ });
+ let mut edits = Edits::default();
+ edits.replace(callee.unwrap(), "::ccc_probe::grpc_channel");
+ edits.insert(src.len(), " // probed");
+ assert_eq!(edits.apply(src), "::ccc_probe::grpc_channel(a, b); // probed");
+ }
+
+ #[test]
+ fn a_missing_token_is_not_a_parse_error_but_an_error_node_is() {
+ let mut parser = Parser::new();
+ parser.set_language(&Language::Cpp.ts_language()).unwrap();
+ let catch2 = parser.parse("TEST_CASE(\"prices\") { REQUIRE(true); }\n", None).unwrap();
+ assert!(catch2.root_node().has_error() && !broken(catch2.root_node()));
+ let garbage = parser.parse("int f( { ] }\n", None).unwrap();
+ assert!(broken(garbage.root_node()));
+ }
+
+ #[test]
+ fn relative_paths_climb_out_of_the_shared_prefix() {
+ assert_eq!(relative("services/storage", ".ccc-probe/go"), "../../.ccc-probe/go");
+ assert_eq!(relative("", ".ccc-probe/go"), ".ccc-probe/go");
+ assert_eq!(relative("a/b", "a/c"), "../c");
+ assert_eq!(relative("a", "a"), ".");
+ }
+
+ #[test]
+ fn a_quoted_name_escapes_what_every_language_would_end_the_string_on() {
+ assert_eq!(quote(r#"it("a \ b")"#), r#""it(\"a \\ b\")""#);
+ }
+
+ #[test]
+ fn the_map_skips_what_the_probes_skip() {
+ assert!(mapped("services/storage/storage.go", 10));
+ assert!(!mapped("vendor/x/y.go", 10));
+ assert!(!mapped(".ccc-probe/go/cccprobe.go", 10));
+ assert!(!mapped("node_modules/a/index.ts", 10));
+ assert!(!mapped("big.go", MAX_FILE_BYTES + 1));
+ }
+}
diff --git a/src/probe/cpp.rs b/src/probe/cpp.rs
new file mode 100644
index 0000000..068481d
--- /dev/null
+++ b/src/probe/cpp.rs
@@ -0,0 +1,378 @@
+// c++ - a `::ccc_probe::hit` atop every function, a fixture guard atop every gtest, catch2, doctest and boost test, threads and
+// cpp-httplib handlers and clients carrying the fixture, grpc++ servers and channels intercepted - the header-only runtime is
+// included by relative path, with a `#line` after it so every line keeps its number
+
+use super::{collect, dir_of, mapped, named_children, quote, relative, Cx, Prober, Report, Rewrite, Source, RUNTIME_DIR};
+use anyhow::Result;
+use tree_sitter::Node;
+
+const RUNTIME: &str = include_str!("../../probes/cpp/ccc_probe.hpp");
+const HTTPLIB: &str = include_str!("../../probes/cpp/ccc_probe_httplib.hpp");
+const GRPC: &str = include_str!("../../probes/cpp/ccc_probe_grpc.hpp");
+// macros whose body is a test, named by their arguments - `TEST(Suite, Name)` runs as `Suite.Name`
+const GTEST: &[&str] = &["TEST", "TEST_F", "TEST_P", "TYPED_TEST", "TYPED_TEST_P"];
+const BOOST: &[&str] = &["BOOST_AUTO_TEST_CASE", "BOOST_FIXTURE_TEST_CASE", "BOOST_DATA_TEST_CASE"];
+// macros named by a string, whose body follows the call - catch2 and doctest
+const CATCH: &[&str] = &["TEST_CASE", "SCENARIO", "TEST_CASE_METHOD", "TEMPLATE_TEST_CASE"];
+const HTTPLIB_ROUTES: &[&str] = &["Get", "Post", "Put", "Patch", "Delete", "Options"];
+
+#[derive(Default)]
+pub(crate) struct Cpp {
+ // a .h may be included by c sources, which cannot read the runtime
+ c_sources: bool,
+ headers_left: usize,
+ httplib: bool,
+ grpc: bool,
+}
+
+impl Prober for Cpp {
+ fn prepare(&mut self, cx: &Cx) -> Result<()> {
+ self.c_sources = cx.files.iter().any(|f| f.ends_with(".c") && mapped(f, 0));
+ Ok(())
+ }
+
+ fn rewrite(&mut self, _cx: &Cx, file: &Source) -> Result {
+ let mut rw = Rewrite::default();
+ if self.c_sources && file.rel.ends_with(".h") {
+ self.headers_left += 1;
+ return Ok(rw);
+ }
+ for def in file.defs() {
+ let Some(body) = def.node.child_by_field_name("body").filter(|b| b.kind() == "compound_statement") else { continue };
+ let at = body.start_byte() + 1;
+ if let Some(name) = macro_test(file, def.node, &def.func.name) {
+ rw.edits.insert(at, fixture(&name, file));
+ rw.fixtures += 1;
+ continue;
+ }
+ if file.test || def.is_test() || constant(file, def.node) {
+ continue;
+ }
+ rw.edits.insert(at, format!(" ::ccc_probe::hit({}, {}, {});", quote(&file.path), quote(&def.qualified("::")), def.func.start_line));
+ rw.functions += 1;
+ }
+ let uses = |needle: &str| file.imports.iter().any(|i| i.module.contains(needle));
+ let (httplib, grpc) = (uses("httplib.h"), uses("grpcpp/") || uses("grpc++/"));
+ let servers = if httplib { declared(file, &["httplib::Server", "httplib::SSLServer"]) } else { Vec::new() };
+ let (mut httplib_used, mut grpc_used) = (false, false);
+ collect(file.root(), &mut |n| match n.kind() {
+ "expression_statement" => {
+ if let Some(name) = catch_test(file, n) {
+ if let Some(body) = n.next_named_sibling().filter(|b| b.kind() == "compound_statement") {
+ rw.edits.insert(body.start_byte() + 1, fixture(&name, file));
+ rw.fixtures += 1;
+ }
+ }
+ }
+ "call_expression" => {
+ if wrap_thread(file, n, &mut rw) {
+ rw.wrapped += 1;
+ } else if httplib && wrap_route(file, n, &servers, &mut rw) {
+ rw.wrapped += 1;
+ httplib_used = true;
+ } else if grpc && wrap_grpc(file, n, &mut rw) {
+ rw.wrapped += 1;
+ grpc_used = true;
+ }
+ }
+ "declaration" => {
+ if wrap_thread_declaration(file, n, &mut rw) {
+ rw.wrapped += 1;
+ } else if httplib && client_declaration(file, n, &mut rw) {
+ rw.wrapped += 1;
+ httplib_used = true;
+ }
+ }
+ _ => {}
+ });
+ if rw.edits.is_empty() {
+ return Ok(rw);
+ }
+ let dir = dir_of(&file.rel);
+ let (at, line) = include_at(file);
+ rw.edits.insert(at, format!("#include {}\n#line {line}\n", quote(&relative(dir, &format!("{RUNTIME_DIR}/cpp/ccc_probe.hpp")))));
+ for (used, needle, header) in [(httplib_used, "httplib.h", "ccc_probe_httplib.hpp"), (grpc_used, "grpc", "ccc_probe_grpc.hpp")] {
+ if !used {
+ continue;
+ }
+ if let Some((end, next)) = after_include(file, needle) {
+ rw.edits.insert(end, format!("#include {}\n#line {next}\n", quote(&relative(dir, &format!("{RUNTIME_DIR}/cpp/{header}")))));
+ }
+ }
+ self.httplib |= httplib_used;
+ self.grpc |= grpc_used;
+ Ok(rw)
+ }
+
+ fn finish(&mut self, cx: &Cx, report: &mut Report) -> Result<()> {
+ let dir = format!("{RUNTIME_DIR}/cpp");
+ let commit = cx.commit.as_deref().unwrap_or_default();
+ cx.write(&format!("{dir}/ccc_probe.hpp"), &RUNTIME.replacen("inline const char* commit() { return \"\"; }", &format!("inline const char* commit() {{ return {}; }}", quote(commit)), 1))?;
+ report.runtime.push(format!("{dir}/ccc_probe.hpp - header-only, included by relative path, c++17"));
+ if self.httplib {
+ cx.write(&format!("{dir}/ccc_probe_httplib.hpp"), HTTPLIB)?;
+ report.runtime.push(format!("{dir}/ccc_probe_httplib.hpp - cpp-httplib handlers and clients"));
+ }
+ if self.grpc {
+ cx.write(&format!("{dir}/ccc_probe_grpc.hpp"), GRPC)?;
+ report.runtime.push(format!("{dir}/ccc_probe_grpc.hpp - grpc++ interceptors"));
+ }
+ if self.headers_left > 0 {
+ report.skipped.push(format!("{} .h files - the project has c sources that may include them, and c cannot read the runtime", self.headers_left));
+ }
+ report.run.push("cmake --build build && ctest --test-dir build # or however the suites build and run".to_string());
+ Ok(())
+ }
+}
+
+fn fixture(name: &str, file: &Source) -> String {
+ format!(" auto __ccc_test = ::ccc_probe::fixture({}, {}, {});", quote(name), quote(&file.path), quote(file.kind))
+}
+
+// a gtest or boost test macro's name for the test - `TEST(Billing, Charges)` is `Billing.Charges`
+fn macro_test(file: &Source, func: Node, name: &str) -> Option {
+ let gtest = GTEST.contains(&name);
+ if !gtest && !BOOST.contains(&name) {
+ return None;
+ }
+ let declarator = func.child_by_field_name("declarator")?;
+ let params = declarator.child_by_field_name("parameters")?;
+ let args: Vec = named_children(params).iter().map(|p| file.text_of(*p).trim().to_string()).collect();
+ match (gtest, args.as_slice()) {
+ (true, [suite, test, ..]) => Some(format!("{suite}.{test}")),
+ (false, [test, ..]) => Some(test.clone()),
+ _ => None,
+ }
+}
+
+// a catch2 or doctest registration - `TEST_CASE("prices", "[billing]")` names its test by the string
+fn catch_test(file: &Source, stmt: Node) -> Option {
+ let call = stmt.named_child(0).filter(|c| c.kind() == "call_expression")?;
+ let function = call.child_by_field_name("function")?;
+ if function.kind() != "identifier" || !CATCH.contains(&file.text_of(function)) {
+ return None;
+ }
+ let args = call.child_by_field_name("arguments")?;
+ let title = named_children(args).into_iter().find(|a| a.kind() == "string_literal")?;
+ let text = file.text_of(title);
+ Some(text.trim_matches('"').to_string())
+}
+
+// a constexpr or consteval function cannot call the runtime
+fn constant(file: &Source, func: Node) -> bool {
+ let Some(declarator) = func.child_by_field_name("declarator") else { return false };
+ file.text[func.start_byte()..declarator.start_byte()].split(|c: char| !c.is_alphanumeric() && c != '_').any(|w| w == "constexpr" || w == "consteval")
+}
+
+// the names declared with one of these types, local or member
+fn declared(file: &Source, types: &[&str]) -> Vec {
+ let mut out = Vec::new();
+ collect(file.root(), &mut |n| {
+ if !matches!(n.kind(), "declaration" | "field_declaration") {
+ return;
+ }
+ let Some(ty) = n.child_by_field_name("type") else { return };
+ if !types.contains(&file.text_of(ty).replace(char::is_whitespace, "").as_str()) {
+ return;
+ }
+ let mut cursor = n.walk();
+ for d in n.children_by_field_name("declarator", &mut cursor) {
+ let name = d.child_by_field_name("declarator").unwrap_or(d);
+ out.push(file.text_of(name).to_string());
+ }
+ });
+ out
+}
+
+// `std::thread(fn, args)` and `std::async(policy, fn, args)` hand fn the fixture of whoever started it
+fn wrap_thread(file: &Source, call: Node, rw: &mut Rewrite) -> bool {
+ let (Some(function), Some(args)) = (call.child_by_field_name("function"), call.child_by_field_name("arguments")) else { return false };
+ let callee = file.text_of(function).replace(char::is_whitespace, "");
+ let list = named_children(args);
+ let at = match callee.as_str() {
+ "std::thread" | "std::jthread" => 0,
+ "std::async" if list.first().is_some_and(|a| file.text_of(*a).contains("launch")) => 1,
+ "std::async" => 0,
+ _ => return false,
+ };
+ let Some(callable) = list.get(at) else { return false };
+ rw.edits.wrap(*callable, "::ccc_probe::bind_current(", ")");
+ true
+}
+
+// `std::thread t(fn, args);` - the same, declared rather than called
+fn wrap_thread_declaration(file: &Source, decl: Node, rw: &mut Rewrite) -> bool {
+ let Some(ty) = decl.child_by_field_name("type") else { return false };
+ if !matches!(file.text_of(ty).replace(char::is_whitespace, "").as_str(), "std::thread" | "std::jthread") {
+ return false;
+ }
+ let Some(declarator) = decl.child_by_field_name("declarator") else { return false };
+ // `std::thread t(work, n)` with only names inside parses as a function declaration - the vexing parse
+ let args = match declarator.kind() {
+ "init_declarator" => declarator.child_by_field_name("value").filter(|v| matches!(v.kind(), "argument_list" | "initializer_list")),
+ "function_declarator" => declarator.child_by_field_name("parameters"),
+ _ => None,
+ };
+ let Some(callable) = args.and_then(|a| named_children(a).first().copied()) else { return false };
+ rw.edits.wrap(callable, "::ccc_probe::bind_current(", ")");
+ true
+}
+
+// `svr.Get("/x", [](auto& req, auto& res) {...})` on a server declared here - the handler serves each request for its baggage's fixture
+fn wrap_route(file: &Source, call: Node, servers: &[String], rw: &mut Rewrite) -> bool {
+ let (Some(function), Some(args)) = (call.child_by_field_name("function"), call.child_by_field_name("arguments")) else { return false };
+ if function.kind() != "field_expression" {
+ return false;
+ }
+ let receiver = function.child_by_field_name("argument").map(|a| file.text_of(a).to_string()).unwrap_or_default();
+ let route = function.child_by_field_name("field").map(|f| file.text_of(f)).unwrap_or_default();
+ if !servers.contains(&receiver) || !HTTPLIB_ROUTES.contains(&route) {
+ return false;
+ }
+ let Some(handler) = named_children(args).last().copied().filter(|h| h.kind() == "lambda_expression") else { return false };
+ rw.edits.wrap(handler, "::ccc_probe::httplib_serve(", ")");
+ true
+}
+
+// `httplib::Client cli(..);` gains a header writer that sends the fixture on every request
+fn client_declaration(file: &Source, decl: Node, rw: &mut Rewrite) -> bool {
+ let Some(ty) = decl.child_by_field_name("type") else { return false };
+ if !matches!(file.text_of(ty).replace(char::is_whitespace, "").as_str(), "httplib::Client" | "httplib::SSLClient") {
+ return false;
+ }
+ if !decl.parent().is_some_and(|p| p.kind() == "compound_statement") {
+ return false;
+ }
+ let Some(name) = decl.child_by_field_name("declarator").map(|d| d.child_by_field_name("declarator").unwrap_or(d)) else { return false };
+ rw.edits.close(decl.end_byte(), format!(" ::ccc_probe::httplib_client({});", file.text_of(name)));
+ true
+}
+
+// grpc++ channels made with interceptors and servers built with them
+fn wrap_grpc(file: &Source, call: Node, rw: &mut Rewrite) -> bool {
+ let Some(function) = call.child_by_field_name("function") else { return false };
+ match file.text_of(function).replace(char::is_whitespace, "").as_str() {
+ "grpc::CreateChannel" => rw.edits.replace(function, "::ccc_probe::grpc_channel"),
+ "grpc::CreateCustomChannel" => rw.edits.replace(function, "::ccc_probe::grpc_custom_channel"),
+ _ => {
+ let builds = function.kind() == "field_expression" && function.child_by_field_name("field").is_some_and(|f| file.text_of(f) == "BuildAndStart");
+ let Some(builder) = function.child_by_field_name("argument").filter(|_| builds) else { return false };
+ rw.edits.wrap(builder, "::ccc_probe::grpc_server(", ")");
+ }
+ }
+ true
+}
+
+// where the runtime's include goes, and the number of the line it goes ahead of - before the first include the file makes
+// at its top, or inside its include guard, so a macro the file defines first still comes first
+fn include_at(file: &Source) -> (usize, usize) {
+ let mut scope = file.root();
+ loop {
+ let children = named_children(scope);
+ let guard = children.iter().find(|c| !matches!(c.kind(), "comment")).copied().filter(|c| is_guard(file, *c));
+ match guard {
+ Some(g) => scope = g,
+ None => break,
+ }
+ }
+ let children = named_children(scope);
+ let first_include = children.iter().find(|c| c.kind() == "preproc_include");
+ let first_code = children.iter().find(|c| !matches!(c.kind(), "comment" | "preproc_def" | "preproc_call" | "preproc_function_def" | "identifier"));
+ let node = first_include.or(first_code).copied().unwrap_or(scope);
+ let start = file.text[..node.start_byte()].rfind('\n').map_or(0, |i| i + 1);
+ (start, file.text[..start].matches('\n').count() + 1)
+}
+
+// `#ifndef X` followed by `#define X` - a header's include guard
+fn is_guard(file: &Source, node: Node) -> bool {
+ if node.kind() != "preproc_ifdef" || !file.text_of(node).starts_with("#ifndef") {
+ return false;
+ }
+ let name = node.child_by_field_name("name").map(|n| file.text_of(n));
+ let define = named_children(node).into_iter().find(|c| c.kind() == "preproc_def");
+ define.and_then(|d| d.child_by_field_name("name")).map(|n| file.text_of(n)) == name
+}
+
+// the end of the line that includes a library, and the number of the line after it
+fn after_include(file: &Source, needle: &str) -> Option<(usize, usize)> {
+ let mut found = None;
+ collect(file.root(), &mut |n| {
+ if n.kind() == "preproc_include" && n.child_by_field_name("path").is_some_and(|p| file.text_of(p).contains(needle)) {
+ found = Some(n);
+ }
+ });
+ let node = found?;
+ let end = file.text[node.start_byte()..].find('\n').map_or(file.text.len(), |i| node.start_byte() + i + 1);
+ Some((end, file.text[..end].matches('\n').count() + 1))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::super::{Cx, Prober, Source, Target};
+ use super::*;
+ use crate::changes::is_test_path;
+ use crate::extract;
+ use crate::languages::Language;
+ use std::collections::BTreeSet;
+ use std::path::PathBuf;
+
+ fn rewrite(rel: &str, src: &str) -> String {
+ let cx = Cx { target: Target::Cpp, out: PathBuf::new(), prefix: String::new(), commit: None, files: BTreeSet::new(), rules: Vec::new() };
+ let mut parser = tree_sitter::Parser::new();
+ parser.set_language(&Language::Cpp.ts_language()).unwrap();
+ let ex = extract::extract(Language::Cpp, src).unwrap();
+ let file = Source {
+ rel: rel.into(),
+ path: rel.into(),
+ lang: Language::Cpp,
+ text: src.into(),
+ tree: parser.parse(src, None).unwrap(),
+ funcs: ex.funcs,
+ imports: ex.imports,
+ test: is_test_path(rel),
+ kind: cx.kind(rel),
+ };
+ Cpp::default().rewrite(&cx, &file).unwrap().edits.apply(src)
+ }
+
+ #[test]
+ fn every_function_counts_its_runs_and_the_include_keeps_line_numbers() {
+ let src = "#define _GNU_SOURCE\n#include \nnamespace shop {\nint Store::put(int x) const {\n return x;\n}\nconstexpr int twice(int x) { return 2 * x; }\n}\n";
+ let out = rewrite("src/store.cpp", src);
+ assert!(out.starts_with("#define _GNU_SOURCE\n#include \"../.ccc-probe/cpp/ccc_probe.hpp\"\n#line 2\n#include \n"), "{out}");
+ assert!(out.contains("int Store::put(int x) const { ::ccc_probe::hit(\"src/store.cpp\", \"Store::put\", 4);\n"), "{out}");
+ assert!(out.contains("constexpr int twice(int x) { return 2 * x; }"), "a constexpr function cannot call the runtime: {out}");
+ }
+
+ #[test]
+ fn a_header_takes_the_include_inside_its_guard() {
+ let src = "#ifndef SHOP_H\n#define SHOP_H\ninline int price(int n) { return n; }\n#endif\n";
+ let out = rewrite("include/shop.hpp", src);
+ assert!(out.starts_with("#ifndef SHOP_H\n#define SHOP_H\n#include \"../.ccc-probe/cpp/ccc_probe.hpp\"\n#line 3\ninline int price"), "{out}");
+ }
+
+ #[test]
+ fn every_test_macro_is_a_fixture_named_the_way_its_runner_selects_it() {
+ let src = "#include \nTEST(Billing, Charges) {\n EXPECT_EQ(1, 1);\n}\nTEST_CASE(\"prices\", \"[billing]\") {\n REQUIRE(true);\n}\nBOOST_AUTO_TEST_CASE(refunds) {}\nvoid helper() {}\n";
+ let out = rewrite("tests/billing_test.cpp", src);
+ assert!(out.contains("TEST(Billing, Charges) { auto __ccc_test = ::ccc_probe::fixture(\"Billing.Charges\", \"tests/billing_test.cpp\", \"unit\");\n"), "{out}");
+ assert!(out.contains("TEST_CASE(\"prices\", \"[billing]\") { auto __ccc_test = ::ccc_probe::fixture(\"prices\""), "{out}");
+ assert!(out.contains("BOOST_AUTO_TEST_CASE(refunds) { auto __ccc_test = ::ccc_probe::fixture(\"refunds\""), "{out}");
+ assert!(out.contains("void helper() {}"), "{out}");
+ }
+
+ #[test]
+ fn threads_servers_clients_and_channels_carry_the_fixture() {
+ let src = "#include \"httplib.h\"\n#include \nvoid run() {\n httplib::Server svr;\n svr.Get(\"/\", [](const httplib::Request& req, httplib::Response& res) {});\n httplib::Client cli(\"x\");\n cli.Get(\"/\", [](const char*, size_t) { return true; });\n std::thread t(work, 1);\n std::thread u(work, n);\n auto f = std::async(std::launch::async, work);\n auto ch = grpc::CreateChannel(\"x\", creds);\n auto server = builder.BuildAndStart();\n}\n";
+ let out = rewrite("src/server.cpp", src);
+ assert!(out.starts_with("#include \"../.ccc-probe/cpp/ccc_probe.hpp\"\n#line 1\n#include \"httplib.h\"\n#include \"../.ccc-probe/cpp/ccc_probe_httplib.hpp\"\n#line 2\n#include \n#include \"../.ccc-probe/cpp/ccc_probe_grpc.hpp\"\n#line 3\n"), "{out}");
+ assert!(out.contains("svr.Get(\"/\", ::ccc_probe::httplib_serve([](const httplib::Request& req, httplib::Response& res) {}));"), "{out}");
+ assert!(out.contains("httplib::Client cli(\"x\"); ::ccc_probe::httplib_client(cli);"), "{out}");
+ assert!(out.contains("cli.Get(\"/\", [](const char*, size_t) { return true; });"), "a client's own callback stays as it was: {out}");
+ assert!(out.contains("std::thread t(::ccc_probe::bind_current(work), 1);"), "{out}");
+ assert!(out.contains("std::thread u(::ccc_probe::bind_current(work), n);"), "{out}");
+ assert!(out.contains("std::async(std::launch::async, ::ccc_probe::bind_current(work));"), "{out}");
+ assert!(out.contains("::ccc_probe::grpc_channel(\"x\", creds);"), "{out}");
+ assert!(out.contains("::ccc_probe::grpc_server(builder).BuildAndStart();"), "{out}");
+ }
+}
diff --git a/src/probe/go.rs b/src/probe/go.rs
new file mode 100644
index 0000000..d8ad5e2
--- /dev/null
+++ b/src/probe/go.rs
@@ -0,0 +1,450 @@
+// go - a `cccprobe` call atop every function and test, a wrap around every server's handler and client's transport, grpc options on every server and dial
+
+use super::{child_of_kind, collect, dir_of, mapped, named_children, quote, relative, Cx, Prober, Report, Rewrite, Source, RUNTIME_DIR};
+use anyhow::{bail, Result};
+use tree_sitter::Node;
+
+// the runtime's module path - `.invalid` so no proxy is ever asked for it, a replace directive always points it at the copy
+const PROBE_MODULE: &str = "ccc.invalid/probe";
+const GRPC_MODULE: &str = "google.golang.org/grpc";
+const RUNTIME: &str = include_str!("../../probes/go/cccprobe.go");
+const GRPC_GLUE: &str = include_str!("../../probes/go/cccprobegrpc/cccprobegrpc.go");
+
+struct Module {
+ // its directory inside the copy - empty at the top
+ dir: String,
+ path: String,
+ grpc: bool,
+ vendored: bool,
+ probed: bool,
+ grpc_probed: bool,
+}
+
+#[derive(Default)]
+pub(crate) struct Go {
+ modules: Vec,
+}
+
+// the local names this file imports the packages it wraps under
+struct Names {
+ http: Option,
+ httptest: Option,
+ grpc: Option,
+}
+
+impl Go {
+ // the module a file belongs to - the nearest go.mod above it
+ fn module_of(&self, rel: &str) -> Option {
+ (0..self.modules.len())
+ .filter(|&i| {
+ let dir = &self.modules[i].dir;
+ dir.is_empty() || rel.starts_with(&format!("{dir}/"))
+ })
+ .max_by_key(|&i| self.modules[i].dir.len())
+ }
+}
+
+impl Prober for Go {
+ fn prepare(&mut self, cx: &Cx) -> Result<()> {
+ for rel in &cx.files {
+ if !(rel == "go.mod" || rel.ends_with("/go.mod")) || !mapped(rel, 0) || rel.split('/').any(|d| d == "testdata") {
+ continue;
+ }
+ let text = cx.read(rel).unwrap_or_default();
+ let Some(path) = module_path(&text) else { continue };
+ let dir = dir_of(rel).to_string();
+ let vendored = cx.files.contains(&join(&dir, "vendor/modules.txt"));
+ self.modules.push(Module { grpc: requires(&text, GRPC_MODULE), dir, path, vendored, probed: false, grpc_probed: false });
+ }
+ if self.modules.is_empty() {
+ bail!("no go.mod in {} - `ccc probe go` instruments go modules", cx.out.display());
+ }
+ Ok(())
+ }
+
+ fn rewrite(&mut self, _cx: &Cx, file: &Source) -> Result {
+ let Some(m) = self.module_of(&file.rel) else { bail!("no go.mod above it") };
+ let root = file.root();
+ let Some(package) = child_of_kind(root, "package_clause") else { bail!("no package clause") };
+ let package_name = child_of_kind(package, "package_identifier").map(|n| file.text_of(n)).unwrap_or_default();
+ let test_file = file.rel.ends_with("_test.go");
+ let mut rw = Rewrite::default();
+
+ for def in file.defs() {
+ let Some(body) = def.node.child_by_field_name("body") else { continue };
+ let at = body.start_byte() + 1;
+ let method = def.node.kind() == "method_declaration";
+ if test_file {
+ // a TestXxx(t *testing.T) is a fixture - every other function in a test file is a helper, and helpers count nothing
+ if let (false, true, Some(t)) = (method, is_test_name(&def.func.name), test_param(file, def.node)) {
+ rw.edits.insert(at, format!(" cccprobe.Test({t}, {}, {});", quote(&file.path), quote(file.kind)));
+ rw.fixtures += 1;
+ }
+ continue;
+ }
+ // init runs before any test or request could have bound a fixture
+ if def.func.name == "init" && !method {
+ continue;
+ }
+ rw.edits.insert(at, format!(" cccprobe.Hit({}, {}, {});", quote(&file.path), quote(&def.qualified(".")), def.func.start_line));
+ rw.functions += 1;
+ if def.func.name == "main" && !method && package_name == "main" {
+ rw.edits.insert(at, " defer cccprobe.Flush();");
+ }
+ }
+
+ let names = Names {
+ http: file.import_name("net/http"),
+ httptest: file.import_name("net/http/httptest"),
+ grpc: self.modules[m].grpc.then(|| file.import_name(GRPC_MODULE)).flatten(),
+ };
+ let mut grpc_probed = false;
+ collect(root, &mut |n| match n.kind() {
+ "call_expression" => {
+ if let Some(wrapped) = wrap_call(file, n, &names, &mut rw) {
+ rw.wrapped += 1;
+ grpc_probed |= wrapped;
+ }
+ }
+ "composite_literal" => rw.wrapped += wrap_literal(file, n, &names, &mut rw),
+ _ => {}
+ });
+ if rw.edits.is_empty() {
+ return Ok(rw);
+ }
+ let module = &mut self.modules[m];
+ // on the package line itself, so every line keeps the number it had
+ let mut imports = format!("; import cccprobe {}", quote(PROBE_MODULE));
+ if grpc_probed {
+ imports.push_str(&format!("; import cccprobegrpc {}", quote(&format!("{}/internal/cccprobegrpc", module.path))));
+ module.grpc_probed = true;
+ }
+ rw.edits.insert(package.end_byte(), imports);
+ module.probed = true;
+ Ok(rw)
+ }
+
+ fn finish(&mut self, cx: &Cx, report: &mut Report) -> Result<()> {
+ let runtime_dir = format!("{RUNTIME_DIR}/go");
+ cx.write(&format!("{runtime_dir}/go.mod"), &format!("module {PROBE_MODULE}\n\ngo 1.18\n"))?;
+ cx.write(&format!("{runtime_dir}/cccprobe.go"), RUNTIME)?;
+ cx.write(&format!("{runtime_dir}/commit.go"), &commit_file(cx.commit.as_deref()))?;
+ report.runtime.push(format!("{runtime_dir}/ - module {PROBE_MODULE}"));
+
+ for module in self.modules.iter().filter(|m| m.probed) {
+ let gomod = join(&module.dir, "go.mod");
+ let to = local(relative(&module.dir, &runtime_dir));
+ let mut text = cx.read(&gomod).unwrap_or_default();
+ if !text.ends_with('\n') {
+ text.push('\n');
+ }
+ text.push_str(&format!("\n// ccc probe - the runtime the instrumented copy reports through\nrequire {PROBE_MODULE} v0.0.0\n\nreplace {PROBE_MODULE} => {to}\n"));
+ cx.write(&gomod, &text)?;
+ report.edited.push(format!("{gomod} - requires {PROBE_MODULE}, replaced by {to}"));
+ if module.vendored {
+ vendor(cx, module, &to)?;
+ report.edited.push(format!("{} - the runtime vendored beside the rest", join(&module.dir, "vendor/modules.txt")));
+ }
+ if module.grpc_probed {
+ let glue = join(&module.dir, "internal/cccprobegrpc/cccprobegrpc.go");
+ cx.write(&glue, GRPC_GLUE)?;
+ report.runtime.push(format!("{glue} - grpc interceptors for {}", module.path));
+ }
+ }
+ // a workspace's replaces win over its modules' own
+ for rel in cx.files.iter().filter(|r| *r == "go.work" || r.ends_with("/go.work")) {
+ let mut text = cx.read(rel).unwrap_or_default();
+ if !text.ends_with('\n') {
+ text.push('\n');
+ }
+ let to = local(relative(dir_of(rel), &runtime_dir));
+ text.push_str(&format!("\nreplace {PROBE_MODULE} => {to}\n"));
+ cx.write(rel, &text)?;
+ report.edited.push(format!("{rel} - replaces {PROBE_MODULE} with {to}"));
+ }
+ report.run.push("go test -count=1 ./...".to_string());
+ Ok(())
+ }
+}
+
+// a vendored module builds only from vendor/, so the runtime goes there too and modules.txt names it
+fn vendor(cx: &Cx, module: &Module, to: &str) -> Result<()> {
+ let vendored = join(&module.dir, &format!("vendor/{PROBE_MODULE}"));
+ cx.write(&format!("{vendored}/cccprobe.go"), RUNTIME)?;
+ cx.write(&format!("{vendored}/commit.go"), &commit_file(cx.commit.as_deref()))?;
+ let list = join(&module.dir, "vendor/modules.txt");
+ let mut text = cx.read(&list).unwrap_or_default();
+ if !text.is_empty() && !text.ends_with('\n') {
+ text.push('\n');
+ }
+ text.push_str(&format!("# {PROBE_MODULE} v0.0.0 => {to}\n## explicit; go 1.18\n{PROBE_MODULE}\n# {PROBE_MODULE} => {to}\n"));
+ cx.write(&list, &text)
+}
+
+fn commit_file(commit: Option<&str>) -> String {
+ format!("package cccprobe\n\n// the commit the copy was made from - CCC_COMMIT overrides it\nconst commit = {}\n", quote(commit.unwrap_or_default()))
+}
+
+// the server or client a call makes, wrapped - `Some(true)` when the wrap is grpc's
+fn wrap_call(file: &Source, call: Node, names: &Names, rw: &mut Rewrite) -> Option {
+ let function = call.child_by_field_name("function")?;
+ let args = call.child_by_field_name("arguments")?;
+ if function.kind() != "selector_expression" {
+ return None;
+ }
+ let package = file.text_of(function.child_by_field_name("operand")?);
+ let name = file.text_of(function.child_by_field_name("field")?);
+ let list = named_children(args);
+ let is = |local: &Option| local.as_deref() == Some(package);
+ // the handler each server serves, by its place among the arguments
+ let handler = match name {
+ "NewServer" | "NewTLSServer" | "NewUnstartedServer" if is(&names.httptest) => Some(0),
+ "ListenAndServe" | "Serve" | "ServeTLS" if is(&names.http) => Some(1),
+ "ListenAndServeTLS" if is(&names.http) => Some(3),
+ _ => None,
+ };
+ if let Some(at) = handler {
+ let arg = *list.get(at)?;
+ rw.edits.wrap(arg, "cccprobe.Handler(", ")");
+ return Some(false);
+ }
+ if !is(&names.grpc) {
+ return None;
+ }
+ let (first, options, ty) = match name {
+ "NewServer" => (0, "cccprobegrpc.UnaryServer(), cccprobegrpc.StreamServer()", "ServerOption"),
+ "Dial" | "NewClient" => (1, "cccprobegrpc.UnaryClient(), cccprobegrpc.StreamClient()", "DialOption"),
+ "DialContext" => (2, "cccprobegrpc.UnaryClient(), cccprobegrpc.StreamClient()", "DialOption"),
+ _ => return None,
+ };
+ add_options(args, &list, first, options, &format!("{package}.{ty}"), rw)?;
+ Some(true)
+}
+
+// grpc options put in ahead of the caller's own - through an append when the caller spreads a slice
+fn add_options(args: Node, list: &[Node], first: usize, options: &str, ty: &str, rw: &mut Rewrite) -> Option<()> {
+ let spread = list.iter().position(|a| a.kind() == "variadic_argument");
+ match spread {
+ None => match list.get(first) {
+ Some(arg) => rw.edits.insert(arg.start_byte(), format!("{options}, ")),
+ None if first == 0 => rw.edits.insert(args.start_byte() + 1, options.to_string()),
+ None => rw.edits.close(list.get(first - 1)?.end_byte(), format!(", {options}")),
+ },
+ Some(at) if at >= first => {
+ let spread = list[at];
+ let start = list.get(first).copied().unwrap_or(spread);
+ if at > first {
+ rw.edits.insert(start.start_byte(), format!("append([]{ty}{{{options}, "));
+ rw.edits.close(list[at - 1].end_byte(), "}");
+ } else {
+ rw.edits.insert(start.start_byte(), format!("append([]{ty}{{{options}}}, "));
+ }
+ rw.edits.close(spread.end_byte(), ")...");
+ }
+ Some(_) => return None,
+ }
+ Some(())
+}
+
+// an http.Server's handler or an http.Client's transport, wrapped - a server with none gets one that serves the default mux
+fn wrap_literal(file: &Source, lit: Node, names: &Names, rw: &mut Rewrite) -> usize {
+ let (Some(ty), Some(body)) = (lit.child_by_field_name("type"), lit.child_by_field_name("body")) else { return 0 };
+ if ty.kind() != "qualified_type" || names.http.is_none() {
+ return 0;
+ }
+ let package = ty.child_by_field_name("package").map(|n| file.text_of(n));
+ let name = ty.child_by_field_name("name").map(|n| file.text_of(n)).unwrap_or_default();
+ if package != names.http.as_deref() {
+ return 0;
+ }
+ let (field, wrap) = match name {
+ "Server" => ("Handler", "cccprobe.Handler("),
+ "Client" => ("Transport", "cccprobe.Transport("),
+ _ => return 0,
+ };
+ let elements = named_children(body);
+ let keyed = elements.iter().all(|e| e.kind() == "keyed_element");
+ for e in &elements {
+ let key = e.child_by_field_name("key").map(|k| file.text_of(k).trim().to_string());
+ if key.as_deref() == Some(field) {
+ if let Some(value) = e.child_by_field_name("value") {
+ rw.edits.wrap(value, wrap, ")");
+ return 1;
+ }
+ }
+ }
+ // a client with no transport already sends through the default one, which the runtime wraps
+ if name == "Server" && keyed {
+ rw.edits.insert(body.start_byte() + 1, "Handler: cccprobe.Handler(nil), ");
+ return 1;
+ }
+ 0
+}
+
+// the *testing.T a test takes, by name
+fn test_param(file: &Source, func: Node) -> Option {
+ let params = func.child_by_field_name("parameters")?;
+ let list = named_children(params);
+ let [param] = list.as_slice() else { return None };
+ let ty = param.child_by_field_name("type")?;
+ let name = param.child_by_field_name("name")?;
+ let ty = file.text_of(ty).replace(' ', "");
+ let name = file.text_of(name);
+ (ty.ends_with(".T") && ty.starts_with('*') && name != "_").then(|| name.to_string())
+}
+
+// `Test`, `TestCharge`, `Test_charge` - go's own rule, the rest after `Test` must not start lowercase
+fn is_test_name(name: &str) -> bool {
+ name.strip_prefix("Test").is_some_and(|rest| !rest.starts_with(|c: char| c.is_lowercase()))
+}
+
+fn module_path(gomod: &str) -> Option {
+ gomod.lines().find_map(|l| l.trim().strip_prefix("module ").map(|m| m.trim().trim_matches('"').to_string()))
+}
+
+// does a go.mod require the module, in a block or on its own line
+fn requires(gomod: &str, module: &str) -> bool {
+ gomod.lines().any(|l| {
+ let l = l.trim();
+ let l = l.strip_prefix("require ").unwrap_or(l).trim();
+ l.split_whitespace().next() == Some(module)
+ })
+}
+
+// go reads a replace target as a directory only when it starts `./` or `../`
+fn local(path: String) -> String {
+ if path.starts_with("./") || path.starts_with("../") || path == ".." {
+ path
+ } else {
+ format!("./{path}")
+ }
+}
+
+fn join(dir: &str, rel: &str) -> String {
+ if dir.is_empty() {
+ rel.to_string()
+ } else {
+ format!("{dir}/{rel}")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::super::{Cx, Prober, Source, Target};
+ use super::*;
+ use crate::extract;
+ use crate::languages::Language;
+ use std::collections::BTreeSet;
+ use std::path::PathBuf;
+
+ fn cx() -> Cx {
+ Cx { target: Target::Go, out: PathBuf::new(), prefix: String::new(), commit: None, files: BTreeSet::new(), rules: Vec::new() }
+ }
+
+ fn rewrite(rel: &str, src: &str, grpc: bool) -> String {
+ let cx = cx();
+ let mut go = Go { modules: vec![Module { dir: String::new(), path: "example.com/shop".into(), grpc, vendored: false, probed: false, grpc_probed: false }] };
+ let mut parser = tree_sitter::Parser::new();
+ parser.set_language(&Language::Go.ts_language()).unwrap();
+ let ex = extract::extract(Language::Go, src).unwrap();
+ let file = Source {
+ rel: rel.into(),
+ path: rel.into(),
+ lang: Language::Go,
+ text: src.into(),
+ tree: parser.parse(src, None).unwrap(),
+ funcs: ex.funcs,
+ imports: ex.imports,
+ test: rel.ends_with("_test.go"),
+ kind: cx.kind(rel),
+ };
+ let rw = go.rewrite(&cx, &file).unwrap();
+ rw.edits.apply(src)
+ }
+
+ #[test]
+ fn every_function_counts_its_runs_on_the_line_it_started_on() {
+ let src = "package shop // the shop\n\nimport \"fmt\"\n\nfunc (s *Store[T]) Put(id string) error {\n\treturn nil\n}\n\nfunc init() { fmt.Println() }\n\nfunc main() {}\n";
+ let out = rewrite("store.go", src, false);
+ assert!(out.starts_with("package shop; import cccprobe \"ccc.invalid/probe\" // the shop\n"), "{out}");
+ // the owner is spelled the way the map spells it, so a record ties back by owner as well as name
+ assert!(out.contains("func (s *Store[T]) Put(id string) error { cccprobe.Hit(\"store.go\", \"Store[T].Put\", 5);\n"), "{out}");
+ assert!(out.contains("func init() { fmt.Println() }"), "init counts nothing: {out}");
+ // main is only a program's entry in package main
+ assert!(!out.contains("Flush"), "{out}");
+ assert_eq!(out.lines().count(), src.lines().count(), "every line keeps its number");
+ let main = rewrite("cmd/shop/main.go", "package main\n\nfunc main() {\n}\n", false);
+ assert!(main.contains("func main() { cccprobe.Hit(\"cmd/shop/main.go\", \"main\", 3); defer cccprobe.Flush();\n"), "{main}");
+ }
+
+ #[test]
+ fn a_test_names_itself_and_its_helpers_count_nothing() {
+ let src = "package shop\n\nimport \"testing\"\n\nfunc TestPut(t *testing.T) {\n\thelper()\n}\n\nfunc helper() {}\n\nfunc Testify() {}\n";
+ let out = rewrite("integration/put_test.go", src, false);
+ assert!(out.contains("func TestPut(t *testing.T) { cccprobe.Test(t, \"integration/put_test.go\", \"integration\");\n"), "{out}");
+ assert!(out.contains("func helper() {}"), "{out}");
+ assert!(out.contains("func Testify() {}"), "{out}");
+ }
+
+ #[test]
+ fn servers_and_clients_carry_the_fixture() {
+ let src = r#"package shop
+
+import (
+ web "net/http"
+ "net/http/httptest"
+)
+
+func serve(h web.Handler) {
+ _ = httptest.NewServer(h)
+ _ = &web.Server{Addr: ":80", Handler: h}
+ _ = web.Server{Addr: ":81"}
+ _ = &web.Client{Transport: rt}
+ _ = web.ListenAndServe(":82", nil)
+}
+"#;
+ let out = rewrite("serve.go", src, false);
+ assert!(out.contains("httptest.NewServer(cccprobe.Handler(h))"), "{out}");
+ assert!(out.contains("Handler: cccprobe.Handler(h)}"), "{out}");
+ assert!(out.contains("web.Server{Handler: cccprobe.Handler(nil), Addr: \":81\"}"), "{out}");
+ assert!(out.contains("Transport: cccprobe.Transport(rt)}"), "{out}");
+ assert!(out.contains("web.ListenAndServe(\":82\", cccprobe.Handler(nil))"), "{out}");
+ }
+
+ #[test]
+ fn grpc_servers_and_dials_take_the_interceptors_first() {
+ let src = r#"package shop
+
+import g "google.golang.org/grpc"
+
+func run(opts []g.ServerOption, dial []g.DialOption) {
+ _ = g.NewServer()
+ _ = g.NewServer(a, b)
+ _ = g.NewServer(opts...)
+ _ = g.NewServer(a, opts...)
+ _, _ = g.NewClient("x")
+ _, _ = g.DialContext(ctx, "x", dial...)
+}
+"#;
+ let out = rewrite("server.go", src, true);
+ assert!(out.contains("; import cccprobegrpc \"example.com/shop/internal/cccprobegrpc\""), "{out}");
+ assert!(out.contains("g.NewServer(cccprobegrpc.UnaryServer(), cccprobegrpc.StreamServer())"), "{out}");
+ assert!(out.contains("g.NewServer(cccprobegrpc.UnaryServer(), cccprobegrpc.StreamServer(), a, b)"), "{out}");
+ assert!(out.contains("g.NewServer(append([]g.ServerOption{cccprobegrpc.UnaryServer(), cccprobegrpc.StreamServer()}, opts...)...)"), "{out}");
+ assert!(out.contains("g.NewServer(append([]g.ServerOption{cccprobegrpc.UnaryServer(), cccprobegrpc.StreamServer(), a}, opts...)...)"), "{out}");
+ assert!(out.contains("g.NewClient(\"x\", cccprobegrpc.UnaryClient(), cccprobegrpc.StreamClient())"), "{out}");
+ assert!(out.contains("g.DialContext(ctx, \"x\", append([]g.DialOption{cccprobegrpc.UnaryClient(), cccprobegrpc.StreamClient()}, dial...)...)"), "{out}");
+ // a module that does not require grpc keeps grpc's calls as they were
+ let plain = rewrite("server.go", src, false);
+ assert!(!plain.contains("cccprobegrpc"), "{plain}");
+ }
+
+ #[test]
+ fn a_go_mod_names_its_module_and_requirements() {
+ let gomod = "module example.com/shop\n\ngo 1.22\n\nrequire (\n\tgoogle.golang.org/grpc v1.81.1\n)\n";
+ assert_eq!(module_path(gomod).as_deref(), Some("example.com/shop"));
+ assert!(requires(gomod, GRPC_MODULE));
+ assert!(requires("module x\nrequire google.golang.org/grpc v1.0.0\n", GRPC_MODULE));
+ assert!(!requires(gomod, "google.golang.org/grpc/examples"));
+ }
+}
diff --git a/src/probe/python.rs b/src/probe/python.rs
new file mode 100644
index 0000000..006e163
--- /dev/null
+++ b/src/probe/python.rs
@@ -0,0 +1,183 @@
+// python - a `_ccc.hit` atop every function, the runtime imported into every probed file, and every test file and conftest
+// registering it with the pytest that loads them
+
+use super::{dir_of, named_children, quote, relative, Cx, Def, Prober, Report, Rewrite, Source, RUNTIME_DIR};
+use anyhow::Result;
+use std::collections::BTreeMap;
+use tree_sitter::Node;
+
+const RUNTIME: &str = include_str!("../../probes/python/ccc_probe.py");
+// statements that open a block - nothing can share their line ahead of them
+const COMPOUND: &[&str] = &[
+ "function_definition",
+ "class_definition",
+ "decorated_definition",
+ "if_statement",
+ "for_statement",
+ "while_statement",
+ "try_statement",
+ "with_statement",
+ "match_statement",
+];
+
+#[derive(Default)]
+pub(crate) struct Python {
+ // each test file's kind, by its path inside the copy - the pytest plugin reads it back
+ kinds: BTreeMap,
+}
+
+impl Prober for Python {
+ fn prepare(&mut self, _cx: &Cx) -> Result<()> {
+ Ok(())
+ }
+
+ fn rewrite(&mut self, _cx: &Cx, file: &Source) -> Result {
+ let mut rw = Rewrite::default();
+ if file.rel.ends_with(".pyi") {
+ return Ok(rw);
+ }
+ let tests = file.test || file.rel.rsplit('/').next() == Some("conftest.py");
+ if tests {
+ self.kinds.insert(file.rel.clone(), file.kind);
+ rw.fixtures += file.defs().iter().filter(|d| d.is_test()).count();
+ } else {
+ for def in file.defs() {
+ if !def.is_test() && hit(file, &def, &mut rw) {
+ rw.functions += 1;
+ }
+ }
+ if rw.edits.is_empty() {
+ return Ok(rw);
+ }
+ }
+ let to = relative(dir_of(&file.rel), &format!("{RUNTIME_DIR}/python"));
+ let mut import = format!(
+ "import sys as _ccc_sys, os as _ccc_os; _ccc_sys.path.append(_ccc_os.path.join(_ccc_os.path.dirname(_ccc_os.path.abspath(__file__)), {})) if \"ccc_probe\" not in _ccc_sys.modules else None; import ccc_probe as _ccc",
+ quote(&to)
+ );
+ // a test file registers the pytest plugin as pytest collects it
+ if tests {
+ import.push_str("; _ccc.attach()");
+ }
+ place_import(file, &import, &mut rw);
+ Ok(rw)
+ }
+
+ fn finish(&mut self, cx: &Cx, report: &mut Report) -> Result<()> {
+ let path = format!("{RUNTIME_DIR}/python/ccc_probe.py");
+ let runtime = RUNTIME
+ .replacen("COMMIT = \"\"", &format!("COMMIT = {}", quote(cx.commit.as_deref().unwrap_or_default())), 1)
+ .replacen("PREFIX = \"\"", &format!("PREFIX = {}", quote(&cx.prefix)), 1)
+ .replacen("KINDS = {}", &format!("KINDS = {}", serde_json::to_string(&self.kinds)?), 1);
+ cx.write(&path, &runtime)?;
+ report.runtime.push(format!("{path} - imported by every probed file, so no install step"));
+ report.run.push("pytest # or python -m unittest - each test is a fixture either way".to_string());
+ Ok(())
+ }
+}
+
+fn docstring(stmt: Node) -> bool {
+ stmt.kind() == "expression_statement" && stmt.named_child_count() == 1 && stmt.named_child(0).is_some_and(|s| matches!(s.kind(), "string" | "concatenated_string"))
+}
+
+// the count at the top of one function's body - beside its first statement, so its lines keep their numbers where python allows
+fn hit(file: &Source, def: &Def, rw: &mut Rewrite) -> bool {
+ let Some(body) = def.node.child_by_field_name("body") else { return false };
+ let Some(first) = named_children(body).first().copied() else { return false };
+ let call = format!("_ccc.hit({}, {}, {})", quote(&file.path), quote(&def.qualified(".")), def.func.start_line);
+ if docstring(first) {
+ rw.edits.insert(first.end_byte(), format!("; {call}"));
+ } else if COMPOUND.contains(&first.kind()) {
+ // a block opener keeps its line to itself, so the count takes a line of its own above it
+ let line = file.text[..first.start_byte()].rfind('\n').map_or(0, |i| i + 1);
+ let indent = &file.text[line..first.start_byte()];
+ if !indent.trim().is_empty() {
+ return false;
+ }
+ rw.edits.insert(first.start_byte(), format!("{call}\n{indent}"));
+ } else {
+ rw.edits.insert(first.start_byte(), format!("{call}; "));
+ }
+ true
+}
+
+// the import after the docstring and `from __future__` lines, or ahead of the first statement - on a line it shares where it can
+fn place_import(file: &Source, import: &str, rw: &mut Rewrite) {
+ let statements = named_children(file.root());
+ let mut header = None;
+ for (i, s) in statements.iter().enumerate() {
+ if (i == 0 && docstring(*s)) || s.kind() == "future_import_statement" {
+ header = Some(s.end_byte());
+ continue;
+ }
+ break;
+ }
+ match (header, statements.first()) {
+ (Some(end), _) => rw.edits.insert(end, format!("; {import}")),
+ (None, Some(first)) if COMPOUND.contains(&first.kind()) => rw.edits.insert(first.start_byte(), format!("{import}\n")),
+ (None, Some(first)) => rw.edits.insert(first.start_byte(), format!("{import}; ")),
+ (None, None) => rw.edits.insert(0, format!("{import}\n")),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::super::{Cx, Prober, Source, Target};
+ use super::*;
+ use crate::changes::is_test_path;
+ use crate::extract;
+ use crate::languages::Language;
+ use std::collections::BTreeSet;
+ use std::path::PathBuf;
+
+ fn rewrite(python: &mut Python, rel: &str, src: &str) -> String {
+ let cx = Cx { target: Target::Python, out: PathBuf::new(), prefix: String::new(), commit: None, files: BTreeSet::new(), rules: Vec::new() };
+ let mut parser = tree_sitter::Parser::new();
+ parser.set_language(&Language::Python.ts_language()).unwrap();
+ let ex = extract::extract(Language::Python, src).unwrap();
+ let file = Source {
+ rel: rel.into(),
+ path: rel.into(),
+ lang: Language::Python,
+ text: src.into(),
+ tree: parser.parse(src, None).unwrap(),
+ funcs: ex.funcs,
+ imports: ex.imports,
+ test: is_test_path(rel),
+ kind: cx.kind(rel),
+ };
+ python.rewrite(&cx, &file).unwrap().edits.apply(src)
+ }
+
+ #[test]
+ fn every_function_counts_its_runs_and_lines_hold_where_python_lets_them() {
+ let src = "\"\"\"billing\"\"\"\nfrom __future__ import annotations\nimport os\n\n\nclass Ledger:\n def add(self, n):\n \"\"\"adds\"\"\"\n return n\n\n def refund(self, n):\n if n:\n return 1\n return 0\n\n\ndef price(n): return n * 2\n";
+ let out = rewrite(&mut Python::default(), "billing/ledger.py", src);
+ assert!(out.starts_with("\"\"\"billing\"\"\"\nfrom __future__ import annotations; import sys as _ccc_sys"), "{out}");
+ assert!(out.contains("_ccc_os.path.abspath(__file__)), \"../.ccc-probe/python\")) if \"ccc_probe\" not in _ccc_sys.modules else None; import ccc_probe as _ccc\nimport os"), "{out}");
+ assert!(out.contains(" \"\"\"adds\"\"\"; _ccc.hit(\"billing/ledger.py\", \"Ledger.add\", 7)\n"), "{out}");
+ // an `if` keeps its line, so this one count takes a line of its own
+ assert!(out.contains(" def refund(self, n):\n _ccc.hit(\"billing/ledger.py\", \"Ledger.refund\", 11)\n if n:"), "{out}");
+ assert!(out.contains("def price(n): _ccc.hit(\"billing/ledger.py\", \"price\", 17); return n * 2\n"), "{out}");
+ }
+
+ #[test]
+ fn a_test_file_counts_nothing_but_registers_the_plugin_and_its_kind() {
+ let mut python = Python::default();
+ let src = "import requests\n\n\ndef test_charges():\n assert requests.get('x')\n";
+ let out = rewrite(&mut python, "qa/contract/test_shapes.py", src);
+ assert!(out.starts_with("import sys as _ccc_sys"), "{out}");
+ assert!(out.contains("import ccc_probe as _ccc; _ccc.attach(); import requests\n"), "{out}");
+ assert!(!out.contains("_ccc.hit"), "{out}");
+ assert_eq!(python.kinds.get("qa/contract/test_shapes.py"), Some(&"contract"));
+ // a module with nothing to count is left as it was
+ assert_eq!(rewrite(&mut python, "consts.py", "RATE = 2\n"), "RATE = 2\n");
+ }
+
+ #[test]
+ fn the_runtime_has_the_lines_the_rewrite_fills_in() {
+ for line in ["COMMIT = \"\"", "PREFIX = \"\"", "KINDS = {}"] {
+ assert_eq!(RUNTIME.matches(line).count(), 1, "{line}");
+ }
+ }
+}
diff --git a/src/probe/rust.rs b/src/probe/rust.rs
new file mode 100644
index 0000000..7729dfc
--- /dev/null
+++ b/src/probe/rust.rs
@@ -0,0 +1,434 @@
+// rust - a `::ccc_probe::hit` atop every function, a fixture guard atop every test, spawned work and outgoing requests carrying the
+// fixture, a tower layer on axum and tonic servers, and the runtime crate added to every probed package as a path dependency
+
+use super::{collect, dir_of, mapped, named_children, quote, relative, Cx, Prober, Report, Rewrite, Source, RUNTIME_DIR};
+use anyhow::{bail, Result};
+use tree_sitter::{Node, Parser};
+
+const RUNTIME: &str = include_str!("../../probes/rust/lib.rs");
+const MANIFEST: &str = include_str!("../../probes/rust/Cargo.toml");
+// the attributes that make a function a test, by their last path segment - `#[test]`, `#[tokio::test]`, `#[rstest]`
+const TEST_ATTRS: &[&str] = &["test", "rstest", "test_case", "quickcheck", "wasm_bindgen_test"];
+// spawns whose first argument is a future, then those whose first argument is a closure
+const SPAWN_FUTURE: &[&str] = &["tokio::spawn", "tokio::task::spawn", "tokio::task::spawn_local", "task::spawn", "task::spawn_local", "async_std::task::spawn", "actix_rt::spawn", "actix_web::rt::spawn"];
+const SPAWN_FN: &[&str] = &["std::thread::spawn", "thread::spawn", "tokio::task::spawn_blocking", "task::spawn_blocking", "rayon::spawn"];
+const LAYER: &str = "::ccc_probe::tower::Layer";
+
+struct Package {
+ // its directory inside the copy - empty at the top
+ dir: String,
+ edition: String,
+ // the tower layer builds against http 1 - so axum 0.7 and tonic 0.12 on
+ axum: bool,
+ tonic: bool,
+ actix: bool,
+ // why the package is left as it was
+ skip: Option<&'static str>,
+ probed: bool,
+ tower_used: bool,
+}
+
+#[derive(Default)]
+pub(crate) struct Rust {
+ packages: Vec,
+}
+
+impl Rust {
+ fn package_of(&self, rel: &str) -> Option {
+ (0..self.packages.len())
+ .filter(|&i| {
+ let dir = &self.packages[i].dir;
+ dir.is_empty() || rel.starts_with(&format!("{dir}/"))
+ })
+ .max_by_key(|&i| self.packages[i].dir.len())
+ }
+}
+
+impl Prober for Rust {
+ fn prepare(&mut self, cx: &Cx) -> Result<()> {
+ let root = cx.read("Cargo.toml").unwrap_or_default();
+ for rel in &cx.files {
+ if !(rel == "Cargo.toml" || rel.ends_with("/Cargo.toml")) || !mapped(rel, 0) {
+ continue;
+ }
+ let text = cx.read(rel).unwrap_or_default();
+ if !text.lines().any(|l| l.trim() == "[package]") {
+ continue;
+ }
+ let dir = dir_of(rel).to_string();
+ let entry = |name: &str| cx.read(&join(&dir, name)).unwrap_or_default();
+ let skip = if text.lines().any(|l| matches!(l.replace(' ', "").as_str(), "proc-macro=true" | "proc_macro=true")) {
+ Some("a proc-macro crate runs inside the compiler, not the tests")
+ } else if entry("src/lib.rs").contains("#![no_std]") || entry("src/main.rs").contains("#![no_std]") {
+ Some("a no_std crate has no std for the runtime to record through")
+ } else {
+ None
+ };
+ let modern = |name: &str, floor: u32| dep_version(&text, name).or_else(|| dep_version(&root, name)).map_or(true, |v| at_least(&v, floor));
+ let (axum, tonic) = (has_dep(&text, "axum"), has_dep(&text, "tonic"));
+ self.packages.push(Package {
+ edition: edition(&text, &root),
+ axum: axum && modern("axum", 7),
+ tonic: tonic && modern("tonic", 12),
+ actix: has_dep(&text, "actix-web"),
+ dir,
+ skip,
+ probed: false,
+ tower_used: false,
+ });
+ }
+ if self.packages.is_empty() {
+ bail!("no Cargo.toml with a [package] in {} - `ccc probe rust` instruments cargo packages", cx.out.display());
+ }
+ Ok(())
+ }
+
+ fn rewrite(&mut self, _cx: &Cx, file: &Source) -> Result {
+ let Some(p) = self.package_of(&file.rel) else { bail!("no Cargo.toml above it") };
+ let mut rw = Rewrite::default();
+ let pkg = &self.packages[p];
+ if pkg.skip.is_some() {
+ return Ok(rw);
+ }
+ let inside = file.rel.strip_prefix(&format!("{}/", pkg.dir)).unwrap_or(&file.rel);
+ if inside == "build.rs" {
+ return Ok(rw);
+ }
+ // integration tests and benches are test code - their tests are fixtures and nothing else in them counts
+ let test_file = inside.starts_with("tests/") || inside.starts_with("benches/");
+ for def in file.defs() {
+ let Some(body) = def.node.child_by_field_name("body") else { continue };
+ let at = body.start_byte() + 1;
+ if is_test(file, def.node) {
+ rw.edits.insert(at, format!(" let __ccc_test = ::ccc_probe::fixture({}, {}, {});", quote(&def.func.name), quote(&file.path), quote(file.kind)));
+ rw.fixtures += 1;
+ continue;
+ }
+ if test_file || def.func.test_ctx || is_const(file, def.node) {
+ continue;
+ }
+ rw.edits.insert(at, format!(" ::ccc_probe::hit({}, {}, {});", quote(&file.path), quote(&def.qualified("::")), def.func.start_line));
+ rw.functions += 1;
+ if def.func.name == "main" && def.func.owner.is_none() && def.node.parent().is_some_and(|p| p.kind() == "source_file") {
+ rw.edits.insert(at, " let __ccc_main = ::ccc_probe::main_guard();");
+ }
+ }
+ let mut tower_used = false;
+ collect(file.root(), &mut |n| {
+ if n.kind() == "call_expression" {
+ if let Some(tower) = wrap_call(file, n, pkg, &mut rw) {
+ rw.wrapped += 1;
+ tower_used |= tower;
+ }
+ }
+ });
+ if !rw.edits.is_empty() {
+ let pkg = &mut self.packages[p];
+ pkg.probed = true;
+ pkg.tower_used |= tower_used;
+ }
+ Ok(rw)
+ }
+
+ fn finish(&mut self, cx: &Cx, report: &mut Report) -> Result<()> {
+ let runtime_dir = format!("{RUNTIME_DIR}/rust");
+ cx.write(&format!("{runtime_dir}/Cargo.toml"), MANIFEST)?;
+ let commit = cx.commit.as_deref().unwrap_or_default();
+ cx.write(&format!("{runtime_dir}/lib.rs"), &RUNTIME.replacen("const COMMIT: &str = \"\";", &format!("const COMMIT: &str = {};", quote(commit)), 1))?;
+ report.runtime.push(format!("{runtime_dir}/ - crate ccc_probe"));
+ for pkg in &self.packages {
+ if let Some(why) = pkg.skip {
+ report.skipped.push(format!("{} - {why}", join(&pkg.dir, "Cargo.toml")));
+ }
+ }
+ for pkg in self.packages.iter().filter(|p| p.probed) {
+ let manifest = join(&pkg.dir, "Cargo.toml");
+ let path = relative(&pkg.dir, &runtime_dir);
+ let mut text = cx.read(&manifest).unwrap_or_default();
+ if !text.ends_with('\n') {
+ text.push('\n');
+ }
+ let features = if pkg.tower_used { "\nfeatures = [\"tower\"]" } else { "" };
+ text.push_str(&format!("\n# ccc probe - the runtime the instrumented copy reports through\n[dependencies.ccc_probe]\npath = {}{features}\n", quote(&path)));
+ cx.write(&manifest, &text)?;
+ report.edited.push(format!("{manifest} - depends on ccc_probe at {path}"));
+ // a 2015 crate names another crate only once its root declares it
+ if pkg.edition == "2015" {
+ for root in crate_roots(cx, &pkg.dir) {
+ if let Some(text) = cx.read(&root) {
+ cx.write(&root, &declare_extern(&text))?;
+ }
+ }
+ }
+ }
+ report.run.push("cargo test # --test-threads=1 attributes work a test hands to other threads too".to_string());
+ Ok(())
+ }
+}
+
+// the call a spawn, a client or a server makes, wrapped to carry the fixture - `Some(true)` when the wrap needs the tower layer
+fn wrap_call(file: &Source, call: Node, pkg: &Package, rw: &mut Rewrite) -> Option {
+ let function = call.child_by_field_name("function")?;
+ let args = call.child_by_field_name("arguments")?;
+ let callee = file.text_of(function).replace(char::is_whitespace, "");
+ let list = named_children(args);
+ if SPAWN_FUTURE.contains(&callee.as_str()) || SPAWN_FN.contains(&callee.as_str()) {
+ let wrap = if SPAWN_FN.contains(&callee.as_str()) { "::ccc_probe::bind_current_fn(" } else { "::ccc_probe::bind_current(" };
+ rw.edits.wrap(*list.first()?, wrap, ")");
+ return Some(false);
+ }
+ // a reqwest request builder sent with the baggage header on
+ if function.kind() == "field_expression" && list.is_empty() && file.text.contains("reqwest") {
+ let field = function.child_by_field_name("field").map(|f| file.text_of(f));
+ if field == Some("send") {
+ let receiver = function.child_by_field_name("value")?;
+ rw.edits.wrap(receiver, "::ccc_probe::with_baggage(", ", |__b, __v| __b.header(\"baggage\", __v))");
+ return Some(false);
+ }
+ }
+ let imports = |module: &str, name: &str| file.imports.iter().any(|i| i.module.starts_with(module) && i.names.iter().any(|n| n == name));
+ // a tonic request with the baggage in its metadata
+ if pkg.tonic && (callee == "tonic::Request::new" || (callee == "Request::new" && imports("tonic", "Request"))) {
+ rw.edits.wrap(
+ call,
+ "{ let mut __ccc_r = ",
+ "; if let Some(__v) = ::ccc_probe::baggage().and_then(|b| b.parse().ok()) { __ccc_r.metadata_mut().insert(\"baggage\", __v); } __ccc_r }",
+ );
+ return Some(false);
+ }
+ if pkg.tonic && list.is_empty() && callee.ends_with("Server::builder") && file.text.contains("tonic") {
+ rw.edits.close(call.end_byte(), format!(".layer({LAYER})"));
+ return Some(true);
+ }
+ if pkg.axum && (callee == "axum::serve" || (callee == "serve" && imports("axum", "serve"))) {
+ let app = *list.get(1)?;
+ // a router turned into a make-service takes the layer just before it turns
+ let made = (app.kind() == "call_expression")
+ .then(|| app.child_by_field_name("function"))
+ .flatten()
+ .filter(|f| f.kind() == "field_expression" && f.child_by_field_name("field").is_some_and(|n| file.text_of(n).starts_with("into_make_service")));
+ match made.and_then(|f| f.child_by_field_name("value")) {
+ Some(router) => rw.edits.close(router.end_byte(), format!(".layer({LAYER})")),
+ None => rw.edits.wrap(app, "(", format!(").layer({LAYER})")),
+ }
+ return Some(true);
+ }
+ if pkg.actix && list.is_empty() && (callee == "App::new" || callee == "actix_web::App::new") {
+ rw.edits.close(
+ call.end_byte(),
+ ".wrap_fn(|__req, __srv| { let __f = ::ccc_probe::from_baggage(__req.headers().get(\"baggage\").and_then(|v| v.to_str().ok())); ::ccc_probe::bound(__f, ::actix_web::dev::Service::call(__srv, __req)) })",
+ );
+ return Some(false);
+ }
+ None
+}
+
+// `#[test]`, `#[tokio::test(..)]`, `#[rstest]` above the function
+fn is_test(file: &Source, func: Node) -> bool {
+ let mut at = func.prev_sibling();
+ while let Some(node) = at {
+ match node.kind() {
+ "attribute_item" => {
+ let path = node.named_child(0).and_then(|a| a.named_child(0)).map(|p| file.text_of(p)).unwrap_or_default();
+ let last = path.rsplit("::").next().unwrap_or_default();
+ if TEST_ATTRS.contains(&last) {
+ return true;
+ }
+ }
+ "line_comment" | "block_comment" => {}
+ _ => return false,
+ }
+ at = node.prev_sibling();
+ }
+ false
+}
+
+// a const fn cannot call the runtime
+fn is_const(file: &Source, func: Node) -> bool {
+ named_children(func).iter().any(|c| c.kind() == "function_modifiers" && file.text_of(*c).split_whitespace().any(|w| w == "const"))
+}
+
+// the files cargo compiles as crate roots
+fn crate_roots(cx: &Cx, dir: &str) -> Vec {
+ let prefix = if dir.is_empty() { String::new() } else { format!("{dir}/") };
+ cx.files
+ .iter()
+ .filter(|rel| {
+ let Some(inside) = rel.strip_prefix(&prefix) else { return false };
+ matches!(inside, "src/lib.rs" | "src/main.rs")
+ || ["src/bin/", "tests/", "examples/", "benches/"].iter().any(|d| inside.strip_prefix(d).is_some_and(|f| f.ends_with(".rs") && !f.contains('/')))
+ })
+ .cloned()
+ .collect()
+}
+
+// `extern crate ccc_probe;` after a crate root's inner attributes, on a line it shares
+fn declare_extern(text: &str) -> String {
+ let mut parser = Parser::new();
+ if parser.set_language(&crate::languages::Language::Rust.ts_language()).is_err() {
+ return text.to_string();
+ }
+ let Some(tree) = parser.parse(text, None) else { return text.to_string() };
+ let at = named_children(tree.root_node())
+ .into_iter()
+ .find(|n| !matches!(n.kind(), "inner_attribute_item" | "line_comment" | "block_comment"))
+ .map_or(text.len(), |n| n.start_byte());
+ format!("{}extern crate ccc_probe; {}", &text[..at], &text[at..])
+}
+
+// a dependency's version as a manifest names it - inline, in a table or in its own `[dependencies.name]` section
+fn dep_version(manifest: &str, name: &str) -> Option {
+ let mut in_table = false;
+ for line in manifest.lines() {
+ let l = line.trim();
+ if l.starts_with('[') {
+ in_table = l.trim_matches(['[', ']']).rsplit('.').next() == Some(name) && l.contains("dependencies");
+ continue;
+ }
+ if in_table {
+ if let Some(v) = l.strip_prefix("version").map(str::trim).and_then(|v| v.strip_prefix('=')) {
+ return Some(v.trim().trim_matches('"').to_string());
+ }
+ continue;
+ }
+ let Some(rest) = l.strip_prefix(name) else { continue };
+ let rest = rest.trim_start();
+ let Some(value) = rest.strip_prefix('=') else { continue };
+ let value = value.trim();
+ if let Some(v) = value.strip_prefix('"') {
+ return v.split('"').next().map(str::to_string);
+ }
+ if let Some(at) = value.find("version") {
+ return value[at..].split('"').nth(1).map(str::to_string);
+ }
+ }
+ None
+}
+
+fn has_dep(manifest: &str, name: &str) -> bool {
+ manifest.lines().any(|l| {
+ let l = l.trim();
+ l.strip_prefix(name).is_some_and(|r| r.trim_start().starts_with('=') || r.starts_with('.'))
+ || (l.starts_with('[') && l.contains("dependencies") && l.trim_matches(['[', ']']).rsplit('.').next() == Some(name))
+ })
+}
+
+// does a `0.x` or `x.y` requirement allow the release the layer needs - `^0.7`, `0.8.1`, `1`
+fn at_least(version: &str, minor_floor: u32) -> bool {
+ let v = version.trim_start_matches(['^', '~', '=', '>', ' ']);
+ let mut parts = v.split('.').map(|p| p.parse::().ok());
+ match (parts.next().flatten(), parts.next().flatten()) {
+ (Some(0), Some(minor)) => minor >= minor_floor,
+ (Some(major), _) => major >= 1,
+ _ => true,
+ }
+}
+
+// the edition a package compiles as - its own, its workspace's, or 2015 when it names none
+fn edition(manifest: &str, root: &str) -> String {
+ let find = |text: &str| {
+ text.lines().find_map(|l| {
+ let l = l.trim();
+ let v = l.strip_prefix("edition")?.trim_start().strip_prefix('=')?.trim();
+ v.strip_prefix('"').and_then(|v| v.split('"').next()).map(str::to_string)
+ })
+ };
+ if manifest.lines().any(|l| l.replace(' ', "").starts_with("edition.workspace=true") || l.replace(' ', "").starts_with("edition={workspace=true")) {
+ return find(root).unwrap_or_else(|| "2015".to_string());
+ }
+ find(manifest).unwrap_or_else(|| "2015".to_string())
+}
+
+fn join(dir: &str, rel: &str) -> String {
+ if dir.is_empty() {
+ rel.to_string()
+ } else {
+ format!("{dir}/{rel}")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::super::{Cx, Prober, Source, Target};
+ use super::*;
+ use crate::extract;
+ use crate::languages::Language;
+ use std::collections::BTreeSet;
+ use std::path::PathBuf;
+
+ fn package(axum: bool, tonic: bool) -> Package {
+ Package { dir: String::new(), edition: "2021".into(), axum, tonic, actix: true, skip: None, probed: false, tower_used: false }
+ }
+
+ fn rewrite(rel: &str, src: &str, pkg: Package) -> String {
+ let cx = Cx { target: Target::Rust, out: PathBuf::new(), prefix: String::new(), commit: None, files: BTreeSet::new(), rules: Vec::new() };
+ let mut parser = tree_sitter::Parser::new();
+ parser.set_language(&Language::Rust.ts_language()).unwrap();
+ let ex = extract::extract(Language::Rust, src).unwrap();
+ let file = Source {
+ rel: rel.into(),
+ path: rel.into(),
+ lang: Language::Rust,
+ text: src.into(),
+ tree: parser.parse(src, None).unwrap(),
+ funcs: ex.funcs,
+ imports: ex.imports,
+ test: false,
+ kind: cx.kind(rel),
+ };
+ Rust { packages: vec![pkg] }.rewrite(&cx, &file).unwrap().edits.apply(src)
+ }
+
+ #[test]
+ fn every_function_counts_its_runs_and_every_test_is_a_fixture() {
+ let src = "pub struct Store;\nimpl Store {\n pub const fn new() -> Self { Store }\n pub fn put(&self) -> u8 {\n 1\n }\n}\nfn main() {}\n#[cfg(test)]\nmod tests {\n fn helper() {}\n #[tokio::test(flavor = \"multi_thread\")]\n async fn puts() {\n helper();\n }\n}\n";
+ let out = rewrite("src/main.rs", src, package(false, false));
+ assert!(out.contains("pub const fn new() -> Self { Store }"), "a const fn cannot call the runtime: {out}");
+ assert!(out.contains("pub fn put(&self) -> u8 { ::ccc_probe::hit(\"src/main.rs\", \"Store::put\", 4);\n"), "{out}");
+ assert!(out.contains("fn main() { ::ccc_probe::hit(\"src/main.rs\", \"main\", 8); let __ccc_main = ::ccc_probe::main_guard();}"), "{out}");
+ assert!(out.contains(" fn helper() {}"), "a test helper counts nothing: {out}");
+ assert!(out.contains("async fn puts() { let __ccc_test = ::ccc_probe::fixture(\"puts\", \"src/main.rs\", \"unit\");\n"), "{out}");
+ assert_eq!(out.lines().count(), src.lines().count());
+ }
+
+ #[test]
+ fn spawns_and_requests_carry_the_fixture() {
+ let src = "use tonic::Request;\nfn go() {\n tokio::spawn(async move { work().await });\n std::thread::spawn(|| work());\n let r = client.get(url).send();\n let t = Request::new(Ping {});\n}\nfn reqwest() {}\n";
+ let out = rewrite("src/lib.rs", src, package(false, true));
+ assert!(out.contains("tokio::spawn(::ccc_probe::bind_current(async move { work().await }));"), "{out}");
+ assert!(out.contains("std::thread::spawn(::ccc_probe::bind_current_fn(|| work()));"), "{out}");
+ assert!(out.contains("::ccc_probe::with_baggage(client.get(url), |__b, __v| __b.header(\"baggage\", __v)).send();"), "{out}");
+ assert!(out.contains("let t = { let mut __ccc_r = Request::new(Ping {}); if let Some(__v)"), "{out}");
+ }
+
+ #[test]
+ fn servers_take_the_layer() {
+ let src = "use axum::serve;\nasync fn run() {\n axum::serve(l, app.into_make_service()).await;\n serve(l, router(state)).await;\n tonic::transport::Server::builder().add_service(s);\n HttpServer::new(|| App::new().route(\"/\", h));\n}\n";
+ let out = rewrite("src/server.rs", src, package(true, true));
+ assert!(out.contains("axum::serve(l, app.layer(::ccc_probe::tower::Layer).into_make_service())"), "{out}");
+ assert!(out.contains("serve(l, (router(state)).layer(::ccc_probe::tower::Layer))"), "{out}");
+ assert!(out.contains("Server::builder().layer(::ccc_probe::tower::Layer).add_service(s)"), "{out}");
+ assert!(out.contains("App::new().wrap_fn(|__req, __srv| {"), "{out}");
+ // axum 0.6 and tonic before 0.12 speak http 0.2, which the layer does not
+ let old = rewrite("src/server.rs", src, package(false, false));
+ assert!(!old.contains("tower::Layer"), "{old}");
+ }
+
+ #[test]
+ fn a_manifest_names_versions_and_editions_every_way_cargo_allows() {
+ let m = "[package]\nname = \"a\"\n\n[dependencies]\naxum = \"0.6.20\"\ntonic = { version = \"0.12\", features = [\"tls\"] }\n\n[dependencies.actix-web]\nversion = \"4\"\n";
+ assert_eq!(dep_version(m, "axum").as_deref(), Some("0.6.20"));
+ assert_eq!(dep_version(m, "tonic").as_deref(), Some("0.12"));
+ assert_eq!(dep_version(m, "actix-web").as_deref(), Some("4"));
+ assert!(has_dep(m, "actix-web") && has_dep(m, "axum") && !has_dep(m, "reqwest"));
+ assert!(!at_least("0.6.20", 7) && at_least("^0.7", 7) && at_least("0.12", 12) && at_least("1", 12));
+ assert_eq!(edition(m, ""), "2015");
+ assert_eq!(edition("edition = \"2021\"\n", ""), "2021");
+ assert_eq!(edition("edition.workspace = true\n", "[workspace.package]\nedition = \"2024\"\n"), "2024");
+ }
+
+ #[test]
+ fn a_2015_crate_root_declares_the_runtime_after_its_inner_attributes() {
+ assert_eq!(declare_extern("#![allow(x)]\n// c\nmod a;\n"), "#![allow(x)]\n// c\nextern crate ccc_probe; mod a;\n");
+ }
+}
diff --git a/src/probe/typescript.rs b/src/probe/typescript.rs
new file mode 100644
index 0000000..646d49e
--- /dev/null
+++ b/src/probe/typescript.rs
@@ -0,0 +1,215 @@
+// typescript - a `__ccc.hit` atop every function, every `it`/`test` callback wrapped as a fixture, and the runtime installed
+// into the copy's node_modules, where it patches http, https, fetch and grpc-js as it loads
+
+use super::{child_of_kind, collect, named_children, quote, Cx, Def, Prober, Report, Rewrite, Source};
+use anyhow::Result;
+use tree_sitter::Node;
+
+const RUNTIME: &str = include_str!("../../probes/typescript/index.js");
+const RUNTIME_ESM: &str = include_str!("../../probes/typescript/index.mjs");
+const RUNTIME_TYPES: &str = include_str!("../../probes/typescript/index.d.ts");
+// the package the probes import - a bare name resolves from source, from built output and inside every test runner alike
+const PACKAGE: &str = "ccc-probe";
+// the registrars whose callbacks are tests - `describe` and its kin only group them
+const TEST_REGISTRARS: &[&str] = &["it", "test", "specify"];
+// a file importing one of these registers tests wherever it lives
+const TEST_MODULES: &[&str] = &["vitest", "@jest/globals", "node:test", "mocha", "bun:test", "@playwright/test"];
+
+#[derive(Default)]
+pub(crate) struct TypeScript;
+
+impl Prober for TypeScript {
+ fn prepare(&mut self, _cx: &Cx) -> Result<()> {
+ Ok(())
+ }
+
+ fn rewrite(&mut self, _cx: &Cx, file: &Source) -> Result {
+ let mut rw = Rewrite::default();
+ if file.rel.ends_with(".d.ts") || file.rel.ends_with(".d.mts") || file.rel.ends_with(".d.cts") {
+ return Ok(rw);
+ }
+ let tests = file.test || file.imports.iter().any(|i| TEST_MODULES.contains(&i.module.as_str()));
+ if !tests {
+ for def in file.defs() {
+ if def.is_test() {
+ continue;
+ }
+ if hit(file, &def, &mut rw) {
+ rw.functions += 1;
+ }
+ }
+ }
+ if tests {
+ collect(file.root(), &mut |n| {
+ if n.kind() == "call_expression" && wrap_test(file, n, &mut rw) {
+ rw.fixtures += 1;
+ }
+ });
+ }
+ if !rw.edits.is_empty() {
+ rw.edits.insert(import_at(file), format!("import * as __ccc from {}; ", quote(PACKAGE)));
+ }
+ Ok(rw)
+ }
+
+ fn finish(&mut self, cx: &Cx, report: &mut Report) -> Result<()> {
+ let dir = format!("node_modules/{PACKAGE}");
+ let manifest = serde_json::json!({
+ "name": PACKAGE,
+ "version": "0.0.0",
+ "private": true,
+ "description": "ccc's runtime probe - written by `ccc probe typescript`",
+ "main": "index.js",
+ "types": "index.d.ts",
+ "exports": { ".": { "types": "./index.d.ts", "import": "./index.mjs", "require": "./index.js" } },
+ });
+ cx.write(&format!("{dir}/package.json"), &format!("{}\n", serde_json::to_string_pretty(&manifest)?))?;
+ cx.write(&format!("{dir}/index.js"), RUNTIME)?;
+ cx.write(&format!("{dir}/index.mjs"), RUNTIME_ESM)?;
+ cx.write(&format!("{dir}/index.d.ts"), RUNTIME_TYPES)?;
+ cx.write(&format!("{dir}/commit.js"), &format!("module.exports = {};\n", quote(cx.commit.as_deref().unwrap_or_default())))?;
+ report.runtime.push(format!("{dir}/ - imported as \"{PACKAGE}\""));
+ report.run.push("npm test # or however the suites run - jest, vitest, mocha and node --test all work".to_string());
+ Ok(())
+ }
+}
+
+// the count at the top of one function's body - `false` for a signature with no body
+fn hit(file: &Source, def: &Def, rw: &mut Rewrite) -> bool {
+ let Some(body) = def.node.child_by_field_name("body") else { return false };
+ let call = format!("__ccc.hit({}, {}, {})", quote(&file.path), quote(&def.qualified(".")), def.func.start_line);
+ if body.kind() != "statement_block" {
+ // an arrow's expression body becomes a comma expression that counts first
+ rw.edits.wrap(body, format!("({call}, "), ")");
+ return true;
+ }
+ let mut at = body.start_byte() + 1;
+ // a derived constructor's `super()` stays first, for the compilers that insist on it
+ if def.func.name == "constructor" {
+ if let Some(first) = named_children(body).first() {
+ if first.kind() == "expression_statement" && super_call(*first) {
+ at = first.end_byte();
+ }
+ }
+ }
+ rw.edits.insert(at, format!(" {call};"));
+ true
+}
+
+fn super_call(stmt: Node) -> bool {
+ stmt.named_child(0)
+ .filter(|c| c.kind() == "call_expression")
+ .and_then(|c| c.child_by_field_name("function"))
+ .is_some_and(|f| f.kind() == "super")
+}
+
+// `it("charges", cb)` with cb wrapped as a fixture named the way the map labels it - `it("charges")`
+fn wrap_test(file: &Source, call: Node, rw: &mut Rewrite) -> bool {
+ let (Some(callee), Some(args)) = (call.child_by_field_name("function"), call.child_by_field_name("arguments")) else {
+ return false;
+ };
+ let head = file
+ .text_of(callee)
+ .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
+ .find(|s| !s.is_empty())
+ .unwrap_or_default()
+ .to_string();
+ if !TEST_REGISTRARS.contains(&head.as_str()) {
+ return false;
+ }
+ let list = named_children(args);
+ let Some(title) = list.iter().find(|a| matches!(a.kind(), "string" | "template_string")) else { return false };
+ let title = file.text_of(*title).split_whitespace().collect::>().join(" ");
+ let title = title.trim_matches(['"', '\'', '`']);
+ let Some(callback) = list.iter().find(|a| matches!(a.kind(), "arrow_function" | "function_expression")) else {
+ return false;
+ };
+ if title.is_empty() {
+ return false;
+ }
+ let label = format!("{head}(\"{title}\")");
+ rw.edits.wrap(*callback, format!("__ccc.fixture({}, {}, {}, ", quote(&label), quote(&file.path), quote(file.kind)), ")");
+ true
+}
+
+// where the import goes - after a shebang and any directive prologue, on a line that already holds code so no line moves
+fn import_at(file: &Source) -> usize {
+ let root = file.root();
+ let mut at = 0;
+ if let Some(bang) = child_of_kind(root, "hash_bang_line") {
+ at = file.text[bang.end_byte()..].find('\n').map_or(file.text.len(), |n| bang.end_byte() + n + 1);
+ }
+ for stmt in named_children(root) {
+ if stmt.kind() == "hash_bang_line" || stmt.kind() == "comment" {
+ continue;
+ }
+ let directive = stmt.kind() == "expression_statement" && stmt.named_child(0).is_some_and(|s| s.kind() == "string");
+ if !directive {
+ break;
+ }
+ at = stmt.end_byte();
+ }
+ at
+}
+
+#[cfg(test)]
+mod tests {
+ use super::super::{Cx, Prober, Source, Target};
+ use super::*;
+ use crate::changes::is_test_path;
+ use crate::extract;
+ use crate::languages::Language;
+ use std::collections::BTreeSet;
+ use std::path::PathBuf;
+
+ fn rewrite(rel: &str, src: &str) -> String {
+ let cx = Cx { target: Target::TypeScript, out: PathBuf::new(), prefix: String::new(), commit: None, files: BTreeSet::new(), rules: Vec::new() };
+ let lang = if rel.ends_with(".tsx") { Language::Tsx } else { Language::TypeScript };
+ let mut parser = tree_sitter::Parser::new();
+ parser.set_language(&lang.ts_language()).unwrap();
+ let ex = extract::extract(lang, src).unwrap();
+ let file = Source {
+ rel: rel.into(),
+ path: rel.into(),
+ lang,
+ text: src.into(),
+ tree: parser.parse(src, None).unwrap(),
+ funcs: ex.funcs,
+ imports: ex.imports,
+ test: is_test_path(rel),
+ kind: cx.kind(rel),
+ };
+ TypeScript.rewrite(&cx, &file).unwrap().edits.apply(src)
+ }
+
+ #[test]
+ fn every_function_counts_its_runs_and_no_line_moves() {
+ let src = "#!/usr/bin/env node\n\"use strict\";\nimport { a } from \"./a\";\nexport function charge(n: number): number {\n return n;\n}\nconst add = (n: number) => n + 1;\nclass Billing extends Base {\n constructor() {\n super();\n }\n refund(): void {}\n}\nexport function sig(): void;\n";
+ let out = rewrite("src/billing.ts", src);
+ assert!(out.starts_with("#!/usr/bin/env node\n\"use strict\";import * as __ccc from \"ccc-probe\"; \nimport"), "{out}");
+ assert!(out.contains("export function charge(n: number): number { __ccc.hit(\"src/billing.ts\", \"charge\", 4);\n"), "{out}");
+ assert!(out.contains("const add = (n: number) => (__ccc.hit(\"src/billing.ts\", \"add\", 7), n + 1);"), "{out}");
+ assert!(out.contains(" super(); __ccc.hit(\"src/billing.ts\", \"Billing.constructor\", 9);\n"), "{out}");
+ assert!(out.contains("refund(): void { __ccc.hit(\"src/billing.ts\", \"Billing.refund\", 12);}"), "{out}");
+ assert!(out.contains("export function sig(): void;\n"), "{out}");
+ assert_eq!(out.lines().count(), src.lines().count());
+ }
+
+ #[test]
+ fn a_test_callback_runs_as_the_fixture_the_map_names_and_its_file_counts_nothing() {
+ let src = "import { it, describe } from \"vitest\";\ndescribe(\"billing\", () => {\n it(\"charges a fee\", async () => {\n await charge(1);\n });\n it.each([1, 2])(\"refunds %s\", (n) => {});\n test(\"done style\", function (done) { done(); }, 500);\n});\nfunction helper() {}\n";
+ let out = rewrite("src/billing.test.ts", src);
+ assert!(out.starts_with("import * as __ccc from \"ccc-probe\"; import { it, describe }"), "{out}");
+ assert!(out.contains("it(\"charges a fee\", __ccc.fixture(\"it(\\\"charges a fee\\\")\", \"src/billing.test.ts\", \"unit\", async () => {\n await charge(1);\n }));"), "{out}");
+ assert!(out.contains("(\"refunds %s\", __ccc.fixture(\"it(\\\"refunds %s\\\")\", \"src/billing.test.ts\", \"unit\", (n) => {}));"), "{out}");
+ assert!(out.contains("__ccc.fixture(\"test(\\\"done style\\\")\", \"src/billing.test.ts\", \"unit\", function (done) { done(); }), 500);"), "{out}");
+ assert!(out.contains("describe(\"billing\", () => {"), "a group is not a test: {out}");
+ assert!(out.contains("function helper() {}"), "{out}");
+ }
+
+ #[test]
+ fn a_file_with_nothing_to_probe_is_left_alone() {
+ assert_eq!(rewrite("src/types.ts", "export type A = { a: number };\n"), "export type A = { a: number };\n");
+ assert_eq!(rewrite("src/api.d.ts", "export function a(): void;\n"), "export function a(): void;\n");
+ }
+}
diff --git a/src/scan.rs b/src/scan.rs
index d82c67c..a77ab22 100644
--- a/src/scan.rs
+++ b/src/scan.rs
@@ -12,7 +12,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
// don't scan these dirs even with `.gitignore`
-const SKIP_DIRS: &[&str] = &[
+pub(crate) const SKIP_DIRS: &[&str] = &[
".ccc",
".git",
".hg",
@@ -29,7 +29,7 @@ const SKIP_DIRS: &[&str] = &[
];
// skip lage files
-const MAX_FILE_BYTES: u64 = 2_000_000;
+pub(crate) const MAX_FILE_BYTES: u64 = 2_000_000;
pub struct ScanReport {
pub files: usize,
diff --git a/src/serve.rs b/src/serve.rs
index 88c74a3..aa06851 100644
--- a/src/serve.rs
+++ b/src/serve.rs
@@ -2874,7 +2874,7 @@ const INTENT_DOC: &str = "required - why THIS change, in one terse line specific
// what a read tool's `why` asks for
const WHY_DOC: &str = "why you are looking, in one terse line (`find where the retry is set`) - shown on the visualiser's timeline so a person watching follows your reasoning (optional)";
// the edit tools that stage a change, each of which must say why
-const STAGING: &[&str] = &["edit_rename", "edit_replace", "edit_delete", "edit_insert", "edit_text"];
+const STAGING: &[&str] = &["edit_rename", "edit_replace", "edit_delete", "edit_insert", "edit_text", "edit_move"];
fn mcp_tools() -> Value {
let tool = |name: &str, desc: &str, props: Value, required: &[&str]| {
@@ -3144,7 +3144,7 @@ fn mcp_tools() -> Value {
),
tool(
"edit_text",
- "EVERY OTHER WRITE TO THIS PROJECT - lines inside a function, a config or doc file, a new file, a removed file. With `old`: replaces that exact text in `path`; it must occur once unless all=true, whitespace and indentation included. Without `old`: writes `new` as the whole file, creating it and its directories if needed. delete=true removes the file. The path stays inside the project root - no `..`, no symlinked directory or file, never `.git`, and under `.ccc` only map.json and surface.json. Stages a changeset and returns its diff; pass apply=true to write it in the same call when the diff would tell you nothing new.",
+ "EVERY OTHER WRITE TO THIS PROJECT - lines inside a function, a config or doc file, a new file, a removed file. With `old`: replaces that exact text in `path`; it must occur once unless all=true, whitespace and indentation included. Without `old`: writes `new` as the whole file, creating it and its directories if needed. delete=true removes the file, and any directory that leaves empty. To move or rename a file or directory, use `edit_move`. The path stays inside the project root - no `..`, no symlinked directory or file, never `.git`, and under `.ccc` only map.json and surface.json. Stages a changeset and returns its diff; pass apply=true to write it in the same call when the diff would tell you nothing new.",
json!({
"path": {"type": "string", "description": "project-relative path"},
"old": {"type": "string", "description": "exact text to replace (omit to write the whole file)"},
@@ -3157,6 +3157,18 @@ fn mcp_tools() -> Value {
}),
&["path", "intent"],
),
+ tool(
+ "edit_move",
+ "MOVES OR RENAMES A FILE OR A WHOLE DIRECTORY in one call, instead of writing each file again at its new path and removing the old one. `from` is a file or a directory; `to` is the path it becomes, which must be free (an empty directory may stand there). A directory goes with every file under it, hidden ones included; each file's text goes over as it is, and a directory the move empties is removed. Refuses a symlink inside, a file that is not text, a move into itself, and the paths `edit_text` refuses - outside the root, in `.git`, or under `.ccc` other than map.json and surface.json. The diff gives one `rename old -> new` line per file rather than its text. Nothing that refers to the old path is changed - `find` those and fix them with `edit_text`, in the same changeset if you like. Stages a changeset and returns its diff.",
+ json!({
+ "from": {"type": "string", "description": "project-relative file or directory to move"},
+ "to": {"type": "string", "description": "the project-relative path it becomes"},
+ "changeset": {"type": "string", "description": "stage into this pending changeset instead of opening a new one"},
+ "intent": {"type": "string", "description": INTENT_DOC},
+ "apply": {"type": "boolean", "description": "write it in this same call (default false)"},
+ }),
+ &["from", "to", "intent"],
+ ),
tool(
"edit_apply",
"WRITES A STAGED CHANGESET - every file or none. Each file is checked against what it held when it was staged (one that changed since is refused, and the changeset stays pending), written through a temp file and an atomic rename, and the map is rescanned before this returns, so there is no `refresh` to call. A rename is verified by counting what in the project still carries the old name. The answer says `applied changeset `; that id is what `edit_revert` takes.",
@@ -3260,7 +3272,8 @@ fn mcp_initialize(params: &Value, auto: bool) -> Value {
whole definition at each site, `edit_delete` removes definitions, statement \
calls, imports or notes and refuses while anything still names what it removes, \
`edit_insert` adds code beside a site. For anything else - lines inside a \
- function, config, docs, a new or removed file - `edit_text`. Each stages a \
+ function, config, docs, a new or removed file - `edit_text`; to move or rename \
+ a file or a whole directory, `edit_move`, never `mv`. Each stages a \
changeset and shows its diff: read it, then `edit_apply` it (or pass apply=true \
when the diff would tell you nothing new); `edit_discard` drops one, \
`edit_revert` undoes an applied one. Every staging call needs `intent`, one \
@@ -3305,8 +3318,8 @@ fn mcp_initialize(params: &Value, auto: bool) -> Value {
project's own code - secrets, disabled TLS, injection surfaces. `search` \
is the only way to the web, internal portals included, and `violations` \
reports where agents broke these rules. Edits: \
- `edit_rename`, `edit_replace`, `edit_delete`, `edit_insert` and `edit_text` \
- stage a changeset, `edit_apply` writes it, `edit_discard` drops it, \
+ `edit_rename`, `edit_replace`, `edit_delete`, `edit_insert`, `edit_text` and \
+ `edit_move` stage a changeset, `edit_apply` writes it, `edit_discard` drops it, \
`edit_revert` undoes it. \
The analysis tools are \
heuristics over a syntax tree - no type inference, data flow or runtime \
@@ -4361,14 +4374,21 @@ fn md_observed(v: &Value, select: Option<&str>) -> Option<(String, String)> {
);
body.push_str(&md_commands(&jarr(sel, "commands"), &format!("test_triggers.observed.{mode}.commands")));
for f in &fixtures {
- let covers: Vec = jarr(f, "covers").iter().filter_map(|c| c.as_str().map(str::to_string)).collect();
+ // each changed function it ran - with how many times, when its probe counted
+ let ran: Vec = jarr(f, "ran")
+ .iter()
+ .map(|r| match r["calls"].as_u64() {
+ Some(n) => format!("{} ×{n}", jstr(r, "function")),
+ None => jstr(r, "function").to_string(),
+ })
+ .collect();
let place = if jstr(f, "file").is_empty() { "(outside this repo)".to_string() } else { at(f) };
body.push_str(&format!(
"[{}] {} {} - ran {}{}\n",
jstr(f, "kind"),
place,
jstr(f, "fixture"),
- if covers.is_empty() { "nothing that changed".to_string() } else { covers.join(", ") },
+ if ran.is_empty() { "nothing that changed".to_string() } else { ran.join(", ") },
if jbool(f, "changed") { " - and changed itself" } else { "" },
));
}
@@ -5149,6 +5169,7 @@ fn edit_tool_call(state: &RwLock, name: &str, args: &Value) -> Value {
&stage,
)
}),
+ "edit_move" => need("from").and_then(|f| need("to").and_then(|t| edit::move_path(&ctx, store, &f, &t, &stage))),
"edit_apply" => need("changeset").and_then(|c| edit::apply(&map.root, store, &c)),
"edit_discard" => edit::discard(store, s("changeset").as_deref()),
"edit_revert" => need("changeset").and_then(|c| edit::revert(&map.root, store, &c)),
@@ -7239,6 +7260,7 @@ mod tests {
"edit_delete",
"edit_insert",
"edit_text",
+ "edit_move",
"edit_apply",
"edit_discard",
"edit_revert",
@@ -7486,8 +7508,9 @@ mod tests {
// the observed section shows the selection asked for, and says when a cover was only found greedily
#[test]
fn test_triggers_render_the_observed_selection_asked_for() {
- let fixture = |name: &str| {
- json!({"fixture": name, "file": "tests/test_api.py", "line": 3, "kind": "integration", "covers": ["Fee"], "changed": false})
+ let fixture = |name: &str, calls: Value| {
+ json!({"fixture": name, "file": "tests/test_api.py", "line": 3, "kind": "integration", "covers": ["Fee"],
+ "ran": [{"function": "Fee", "file": "svc/a.go", "calls": calls}], "changed": false})
};
let v = json!({
"available": true,
@@ -7496,9 +7519,9 @@ mod tests {
"records": 4,
"sources": [".ccc/observed"],
"observed_functions": 1,
- "safe": {"fixtures": [fixture("test_refund"), fixture("test_soak")], "cost": 458000, "exact": true,
+ "safe": {"fixtures": [fixture("test_refund", json!(2)), fixture("test_soak", json!(400))], "cost": 458000, "exact": true,
"commands": [{"language": "python", "command": "pytest -k \"test_refund or test_soak\"", "selects": 2, "caveat": ""}]},
- "minimal": {"fixtures": [fixture("test_refund")], "cost": 8000, "exact": false, "commands": []},
+ "minimal": {"fixtures": [fixture("test_refund", Value::Null)], "cost": 8000, "exact": false, "commands": []},
"unobserved": [{"file": "svc/a.go", "function": "Spare"}],
"errors": [],
},
@@ -7508,9 +7531,12 @@ mod tests {
assert!(safe.contains("## observed - safe") && safe.contains("test_soak"), "{safe}");
assert!(safe.contains("$ pytest -k \"test_refund or test_soak\""), "{safe}");
assert!(safe.contains("1 changed function(s) no recorded fixture ran"), "{safe}");
+ assert!(safe.contains("test_soak - ran Fee ×400") && safe.contains("test_refund - ran Fee ×2"), "{safe}");
let minimal = md_triggers(&v, &json!({}), Some("minimal"), page);
assert!(minimal.contains("## observed - minimal") && !minimal.contains("test_soak"), "{minimal}");
assert!(minimal.contains("found greedily"), "{minimal}");
+ // a probe that does not count leaves the bare name
+ assert!(minimal.contains("test_refund - ran Fee\n"), "{minimal}");
// a report without records has no observed section at all
let plain = md_triggers(&json!({"available": true}), &json!({}), None, page);
assert!(!plain.contains("observed"), "{plain}");
@@ -8531,6 +8557,22 @@ mod tests {
assert!(read(&dir, ".gitignore").contains(&format!("/.ccc/{}", edit::LEDGER_NAME)));
}
+ #[test]
+ fn edit_move_carries_a_directory_and_the_map_follows() {
+ let (dir, state) = scratch("move", &[("src/lib.rs", "pub fn a() {}\n"), ("src/util/b.rs", "pub fn helper() {}\n")]);
+ let (err, staged) = tool(&state, "edit_move", json!({"from": "src/util", "to": "src/helpers"}));
+ assert!(!err && staged.contains("rename src/util/b.rs -> src/helpers/b.rs"), "{staged}");
+ let (err, out) = tool(&state, "edit_apply", json!({"changeset": id_after(&staged, "changeset `")}));
+ assert!(!err && out.contains("src/helpers/b.rs (moved from src/util/b.rs)"), "{out}");
+ assert_eq!(read(&dir, "src/helpers/b.rs"), "pub fn helper() {}\n");
+ assert!(!dir.0.join("src/util").exists(), "the emptied directory is gone");
+ // the rescanned map finds the function at its new path
+ let (_, found) = tool(&state, "find", json!({"query": "helper"}));
+ assert!(found.contains("src/helpers/b.rs"), "{found}");
+ let (err, out) = tool(&state, "edit_move", json!({"from": "src/lib.rs"}));
+ assert!(err && out.contains("missing `to`"), "{out}");
+ }
+
#[test]
fn the_edit_tools_refuse_a_browser_page() {
let (dir, state) = scratch("origin", &[("a.txt", "x\n")]);
diff --git a/src/violations.rs b/src/violations.rs
index b63dc97..6ca19fb 100644
--- a/src/violations.rs
+++ b/src/violations.rs
@@ -59,7 +59,7 @@ impl Rule {
// the ccc tool that does the job instead
pub fn instead(self) -> &'static str {
match self {
- Rule::WritesOutsideCcc => "edit_text, or edit_rename / edit_replace / edit_delete / edit_insert on a find or references handle, then edit_apply",
+ Rule::WritesOutsideCcc => "edit_text, edit_move for a moved or renamed file or directory, or edit_rename / edit_replace / edit_delete / edit_insert on a find or references handle, then edit_apply",
Rule::ShellExploration => "index, find, references, file, dependencies or notes",
Rule::WebOutsideSearch => "search",
Rule::BlockedUrl => "nothing - the user's search rules refuse it",