diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..94b21f7 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,419 @@ +# Contributing + +Thanks for looking. This repository is `libtmux` for .NET: a typed, +asynchronous client for tmux, plus a query layer, a workspace builder, and an +MCP server. The gates below are what a change has to pass. + +This file is how we work. For how we write — README prose, `CHANGELOG.md`, +release notes, commit messages, XML documentation, source comments, and error +messages — follow [WRITING.md](WRITING.md). Read it before changing any of +them. + +## Getting set up + +`dotnet` is pinned to `10.0.302` by [`global.json`](../global.json) with +`rollForward: disable`, and again by `.tool-versions`. It resolves through +[mise](https://mise.jdx.dev) locally, so it is not on `PATH` — every command +below is prefixed accordingly: + +```console +$ mise exec -- dotnet build LibTmux.slnx --configuration Release --warnaserror +``` + +CI does not use mise. It installs the same SDK with `actions/setup-dotnet` +from `global-json-file` and calls `dotnet` directly, so a workflow file never +carries the prefix. + +The validators are Python and run through [uv](https://docs.astral.sh/uv/). +There is no Python project file — each script carries its own PEP 723 header. + +You also need a real `tmux`, version 3.2a or newer. The suite drives one +rather than mocking it, because this library's job is being right about tmux +and only tmux can say whether it is. + +## Own your tmux socket root + +Give this repository a socket root of its own before running anything: + +- Tests: `TMUX_TMPDIR=/tmp/libtmux-dotnet-test` +- Servers you start by hand: `TMUX_TMPDIR=/tmp/libtmux-dotnet-dev` + +This matters more than it looks. Several libtmux ports live on this machine +and run real tmux at the same time. A socket in the default root is reachable +by all of them, so one port's cleanup sweep kills another port's servers +mid-run — and the failure surfaces in whichever suite noticed first, which is +rarely the one that caused it. That misattribution is what turns socket +sharing into a debugging loop. + +tmux reads `TMUX_TMPDIR` when it execs and puts a `-L name` socket in +`$TMUX_TMPDIR/tmux-$UID/name`, so exporting it before the run is enough. A +`-S path` socket ignores it and needs a path under the root instead. + +Two things are never safe here, because the processes and directories belong +to other workspaces: + +- `pkill tmux`, or any kill by a pattern matching more than your own root +- deleting `/tmp/tmux-$UID/` or another port's root + +To find what this repository left behind, list its root rather than matching +process names: + +```console +$ ls /tmp/libtmux-dotnet-test/tmux-$(id -u) +``` + +A socket file outlives the server that made it, so read that listing as +candidates and confirm each with `has-session` before deciding it is alive. + +## Building + +Restore against the lock files, check formatting, then build with warnings as +errors: + +```console +$ mise exec -- dotnet restore LibTmux.slnx --locked-mode +``` + +```console +$ mise exec -- dotnet format LibTmux.slnx --verify-no-changes --no-restore +``` + +```console +$ mise exec -- dotnet build \ + LibTmux.slnx \ + --configuration Release \ + --no-restore \ + --warnaserror +``` + +The build is the style guide. `TreatWarningsAsErrors`, `Nullable`, +`EnforceCodeStyleInBuild`, and analyzers at `10-recommended` are set +repository-wide, and `CS1591` is unsuppressed in every shipped project. If it +compiles clean, the formatting is right and every public member is documented. + +## Running the tests + +Unit tests run on both target frameworks: + +```console +$ mise exec -- dotnet test \ + --project tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj \ + --configuration Release \ + --framework net8.0 \ + --no-build \ + --minimum-expected-tests 1 +``` + +The integration suite drives a real tmux and needs the socket root above: + +```console +$ mise exec -- dotnet test \ + --project tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj \ + --configuration Release \ + --framework net10.0 \ + --no-build \ + --minimum-expected-tests 1 +``` + +## Checks that must pass + +[`.github/workflows/dotnet.yml`](workflows/dotnet.yml) is the source of truth, +and its `gate` job is the single name branch protection requires. `gate` needs +`build` and nothing else, so adding a required job means adding it to `gate`'s +`needs` rather than to a protection rule. + +### Order matters before the integration suite + +The packaging tests inside the integration suite read what a pack produced, so +they fail on a tree nobody packed. Run the workflow's order — pack, then the +package consumer, then the ahead-of-time publish — or expect +`PackageClosureTests` to fail for a reason that is not a bug: + +```console +$ mise exec -- dotnet pack \ + LibTmux.slnx \ + --configuration Release \ + --no-build \ + --output artifacts/packages +``` + +```console +$ uv run python eng/parity/inspect_packages.py +``` + +```console +$ mise exec -- dotnet run \ + --project tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj \ + --configuration Release \ + --framework net8.0 \ + --no-restore +``` + +```console +$ mise exec -- dotnet publish \ + tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj \ + --configuration Release \ + --framework net10.0 \ + --runtime linux-x64 +``` + +`LibTmux.PackageConsumer` is deliberately absent from `LibTmux.slnx`. It exists +to prove the packaged artifact rather than a project reference, so it restores +and runs standalone. + +### Validators that read documents, not the build + +Five checks run against documents rather than code, which is what makes them +easy to forget locally: + +```console +$ uv run python eng/parity/verify_public_api.py +``` + +```console +$ uv run python eng/parity/verify_capabilities.py +``` + +```console +$ uv run python eng/parity/verify_workflows.py +``` + +```console +$ uv run python eng/docs/sync_snippets.py --check +``` + +```console +$ uv run eng/mcp/dump_tools.py --check +``` + +`sync_snippets.py --check` is the one that catches a hand-edited example. It +compares each published block against the region it was quoted from and fails +on any difference, so bring a change across rather than typing it into the +document: + +```console +$ uv run python eng/docs/sync_snippets.py +``` + +The examples themselves are checked by the test suites rather than by a script. +`ReadmeExampleTests`, inside the integration suite, compiles every C# block in +the shipped documents and runs the ones tagged `csharp run`; +`SnippetContractTests`, inside the example suite, holds every published region +to an example that runs. [`examples/README.md`](../examples/README.md) has the +whole mechanism. + +The engineering scripts have tests of their own: + +```console +$ uv run --with pytest --with tomlkit python -m pytest eng --quiet +``` + +### NU1004, which is not a dependency problem + +Publishing ahead of time names a runtime identifier, and restore then writes +one into the lock file of every project in that graph — including the +library's, where the section is empty because no package resolves differently. +That is why `src/LibTmux` and `src/LibTmux.Generators` declare +`RuntimeIdentifiers`: without it the lock files disagree with the projects and +the *next* `restore --locked-mode` fails with NU1004, which reads like a +dependency problem and is not one. + +Adding a platform to the matrix means adding its identifier there and +regenerating: + +```console +$ mise exec -- dotnet restore LibTmux.slnx --force-evaluate +``` + +### The other workflows + +[`dotnet-tmux.yml`](workflows/dotnet-tmux.yml) builds each supported tmux from +source and runs the integration suite against it, behind a `compatibility` job +that plays the same role as `gate`. That is what proves the compatibility +range; the build workflow only ever sees whatever tmux Ubuntu ships. + +`dotnet.yml` also carries an advisory `macos arm64` lane, because the +compatibility claim names macOS and a claim nobody runs is a claim. It runs +`continue-on-error` and is deliberately outside `gate`'s `needs`, so a platform +difference cannot block every commit. It restores without `--locked-mode`, +because the lock files are generated for the Linux runtime identifiers this +repository publishes. + +Every failure that lane has produced was a difference in what the platform put +on the screen rather than in what tmux did. The last two were a runner hostname +61 characters long: bash's prompt then fills 78 of the pane's 80 columns, and +tmux stores the wrap as a real line break, so a capture that does not ask for +`-J` returns typed text split across two lines. **Assertions about text a user +typed capture with `joinWrappedLines`.** + +`codeql.yml` and `scorecard.yml` run on a schedule rather than on the gate, +because what they check can change without a commit. Every action reference is +pinned to a commit SHA with the version in a trailing comment, which is what +stops a moved tag from changing what CI runs. Dependabot maintains those pins. + +## Testing the MCP server means running a real agent + +`src/LibTmux.Mcp` is a stdio server, so the only honest test of its tool +descriptions is whether a model picks the right tool without being told which. +`eng/mcp/mcp_swap.py` points every installed agent CLI at a local build, and +`revert` puts their configs back from the timestamped backup it took: + +```console +$ uv run eng/mcp/mcp_swap.py use \ + --source release \ + --env TMUX_TMPDIR=/tmp/libtmux-dotnet-dev +``` + +Pass `--env TMUX_TMPDIR=...` whenever the sockets under test are not in the +default root. An agent spawns the server with its own environment, so a socket +this shell can see is one the server cannot. + +That same gap is why the swap writes `DOTNET_ROOT` into each config. A +framework-dependent apphost finds its runtime through `DOTNET_ROOT` or `PATH`, +mise puts the SDK in neither, and the failure is silent from the agent's side: +the binary exits before the handshake and the agent reports only that the +server has no tools. + +The tool reference is generated rather than written, so it cannot describe a +surface that is not there: + +```console +$ uv run eng/mcp/dump_tools.py +``` + +A wait takes a control-mode client, which is a real attached client: it shows +up in the user's `list-clients` for as long as the wait runs. It attaches with +`ignore-size` so it never drags the window down to its own size, and the watch +is reference counted per session so it exists only while a wait does. Changing +either of those changes what a user sees on their own screen. + +## What a change is expected to carry + +**A behaviour change needs a test against a real tmux.** This library's job is +being right about tmux, and only tmux can say whether it is. + +**A version-dependent behaviour needs a row in the ledger.** Anything that +differs between 3.2a and 3.7b goes through the capability model, and each +difference names the test that proves it in +[`docs/parity/version-deltas.json`](../docs/parity/version-deltas.json). + +**A public API addition needs five edits, and each will tell you.** The Roslyn +analyzer baseline (`PublicAPI.Unshipped.txt`), the type and its members in +`docs/public-api.json`, its values if it is an enum, and its owning component +in `eng/parity/verify_production_plan.py`. They fail independently and by name; +follow the errors. + +**A documented example is compiled, and a `csharp run` block is executed +against a live tmux.** `ReadmeExampleTests` compiles every C# block in the +shipped READMEs and `docs/modes/` against the real assemblies, and runs the +ones tagged to run against a tmux server of their own. If it does not compile, +it is not documentation. Anchoring a block to a snippet region adds drift +protection on top of that; [`examples/README.md`](../examples/README.md) says +how. Add examples to the READMEs or `docs/modes/`, not to the decision records +— those quote what was run at the time and are not edited to keep compiling. + +**A performance claim needs a recorded run.** See +[`docs/benchmarks`](../docs/benchmarks/README.md). Absolute milliseconds move +by a factor of five on one host, so a claim is stated as a marginal cost or a +ratio, with the tmux, host and date that produced it. + +## The Python original is a separate checkout + +This repository was imported out of a monorepo that also held Python libtmux, +so anything grounded in that source needs to be told where it went: + +```console +$ LIBTMUX_PYTHON_REPOSITORY=~/work/python/libtmux \ + uv run python eng/parity/verify_ledger.py +``` + +## Flaky, or broken? + +Some real-tmux tests are load-sensitive. A single failure is worth re-running +in isolation before blaming a change, and worth investigating rather than +shrugging at. Three signs it is the machine and not the code: the failing test +moves between runs, the failure reads as a missing server or an expired wait +rather than a wrong value, and the file it is in is not one the change touched. + +## Pull requests + +Keep the change narrowly scoped. Unrelated cleanup belongs in its own commit, +or its own pull request. + +A passing gate is evidence only once it has been shown capable of failing, so +pair a new test with a deliberate break that proves it bites. + +Commit format is in [WRITING.md](WRITING.md). + +## Review + +A reviewer is checking two things beyond correctness: that the change carries +what the section above requires, and that anything a reader will see follows +[WRITING.md](WRITING.md). A comment that restates its code, a changelog entry +that describes effort rather than impact, or a public member without a +`` are all review findings, not nits. + +## Releases + +Releases are cut by the owner, in two commits. + +First, `chore(release[version]): Bump to 0.0.0-alpha.N` edits +[`Directory.Build.props`](../Directory.Build.props) — `VersionSuffix` and +`PackageReleaseNotes` — and renames `## [Unreleased]` in `CHANGELOG.md` to the +dated version heading. Versioning is manual `VersionPrefix`/`VersionSuffix`, +and one string covers all four shipped packages. + +Second, a `Tag v0.0.0-alpha.N` commit, then the tag itself. + +**Never create tags. Never push tags.** A tag matching `v*` triggers +[`release.yml`](workflows/release.yml), which verifies the tag matches the +built `Version` property, packs, proves the package installs and runs on both +target frameworks, generates an SBOM and a provenance attestation, and pushes +to NuGet through trusted publishing. Renaming that workflow file breaks the +trusted-publishing policy registered on nuget.org, so change the policy first. + +### Recorded evidence is a release artifact + +A capability row is `pending` until a matrix run records evidence for it, and +`verified` after. What `verified` claims is exact: these tmux versions, on +these frameworks, at *this tree* — the fingerprint covers every tracked file +outside the evidence directory. Any commit changes it, so a verified row is +true at one commit and stale at the next. + +That is why recording belongs at a release boundary rather than in the gate, +and why `reconcile_versions.py` and `verify_ledger.py` are not in +`dotnet.yml`. Between releases every row is `pending`, which is the honest +state: nobody has run the matrix against this tree. + +To record, on the commit being released: + +```console +$ eng/tmux/run-matrix.sh \ + --evidence-dir docs/parity/evidence/0001 \ + --capability-cohort 0001 \ + tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj +``` + +```console +$ uv run python eng/parity/reconcile_versions.py \ + --evidence docs/parity/evidence/0001/results.ndjson \ + --write +``` + +Commit the bundle and the rewritten `version-deltas.json` together, because the +fingerprint is of the tree that commit produces. A tmux build takes about forty +seconds here and the matrix runs the suite fourteen times, so budget half an +hour. + +## Compatibility + +tmux **3.2a through 3.7b**, on **net8.0** and **net10.0**, on Linux and macOS. +Windows is unsupported. The packages are trim- and ahead-of-time-safe. + +During alpha the public API can change in any release with no deprecation +period, so a consumer pins an exact version. Widening the supported range means +a row in the ledger, an entry in the tmux matrix, and a README that says so. + +## Reporting a vulnerability + +Not here — see [SECURITY.md](../SECURITY.md). diff --git a/.github/WRITING.md b/.github/WRITING.md new file mode 100644 index 0000000..72c4f40 --- /dev/null +++ b/.github/WRITING.md @@ -0,0 +1,491 @@ +# Writing + +How this project writes: `README.md`, `CHANGELOG.md`, release notes, commit +messages, XML documentation, source comments, and error messages. It governs +every surface a reader reaches, and applies to a one-line `` as much +as to a release announcement. + +[CONTRIBUTING.md](CONTRIBUTING.md) covers how we work — the toolchain, the +gates, what a change has to carry. This covers how we write. + +## Voice + +Calm, literal, precise, useful. Write as though the reader is competent, busy, +and may eventually have to debug this library at 2 a.m. + +- **Understated, not enthusiastic.** "Reads one option." Not "Effortlessly + access the full power of tmux options!" +- **Declarative, not chatty.** "Pass `-l` when it is set." Not "All you have to + do is set the flag." +- **Specific, not clever.** "tmux 3.4 escapes a dollar sign twice." Not + "Handles version quirks intelligently." +- **Outcome before implementation.** Say what the caller observes, then how it + works if that knowledge is part of the contract. +- **Sentence-case headings.** "Running the tests", not "Running The Tests". +- **Adjectives only when falsifiable.** `allocation-free`, `thread-safe`, + `trim-safe`, `O(n)` carry information. `powerful`, `seamless`, `robust`, + `blazing-fast` do not. + +The most useful editing operation is deleting the introductory sentence. + +| Instead of | Prefer | +| --- | --- | +| "We added…" | "`Pane.CaptureAsync` now…" | +| "New and improved" | "`Foo` now…" | +| "powerful", "seamless" | state the capability | +| "easily", "simply" | omit | +| "robust" | name the failure that is handled | +| "comprehensive" | name what is covered | +| "production-ready" | state the guarantee | +| "optimized" | give the magnitude | +| "various fixes" | name the components | +| "under the hood" | omit unless observable | +| "please note that" | state the fact | +| "leverage", "utilize" | "use" | +| "delve into" | "read", or omit | +| "best practices" | name the practice | +| "in order to" | "to" | + +## README + +The README is an onboarding path, not the project's biography. It is also +package metadata: nuget.org renders it, so it is the first thing most readers +of this library ever see. + +The first screen carries the title, one sentence saying exactly what the +library does, the badges, and a minimal example that compiles. Everything else +comes after. + +Show the golden path first, then configuration, then the advanced case. +Architecture and design notes go late, or in `docs/` — a reader wants to know +whether they need the library before they are told how it is built. + +State compatibility explicitly rather than making somebody open a `.csproj`. +The supported tmux versions, the target frameworks, the supported operating +systems, and trim and ahead-of-time safety each get a row. + +Examples are product surface. Every one is compilable, copyable, idiomatic, +and explicit about setup — no elided `using` directives, no `// setup omitted`, +no placeholder methods that do not exist. An example that assumes invisible +context is hostile to a newcomer and useless to an agent. If something is +deliberately left out, say so in prose. + +Put exact symbols in prose. "Set `SendKeysRequest.Literal` to `true`" beats +"turn on the literal flag" — it lets a reader jump straight to the code. + +Prefer boring, predictable headings. "Compatibility" and "Documentation" +retrieve well; "When things get weird" does not. + +## Changelog + +`CHANGELOG.md` is a consumer-facing compatibility ledger, not `git log`. Every +bullet answers one question: what changed for somebody consuming the package? +A refactor nobody can observe is not an entry. + +One change per bullet. Lead with the identifier and a concrete verb — add, +fix, remove, deprecate, `now`, `no longer`. Name identifiers literally: +`Pane.CaptureAsync`, `TMUX_TMPDIR`, `tmux://panes/{pane}`. + +Group under `### Added`, `### Changed`, and `### Fixed`. Bold the opening +sentence of anything a reader must act on; leave the rest plain. + +Entries land under `## [Unreleased]`. The maintainer assigns the version when +cutting a release, so nothing here predicts one. A released heading is +`## [0.0.0-alpha.N] — YYYY-MM-DD`, and every bracketed heading gets a matching +reference-link definition at the bottom of the file — a bracket with no +definition renders as literal text. + +Do not sell a fix. "No longer truncates panes wider than 500 columns", not +"improves capture reliability". Do not describe effort. Give the old behaviour +only where it explains a break. + +State a changed default explicitly, and an incompatibility more explicitly +still, with the way forward in the same bullet. For a break worth spelling +out, use the four-part frame: + +```markdown +**`Server.KillAsync` now throws instead of returning `false`.** + +- Previous behaviour: returned `false` when no server was running. +- New behaviour: throws `TmuxNotRunningException`. +- Reason: the silent `false` masked a misconfigured socket path. +- Recommended action: check `Server.IsRunning`, or catch the exception. +``` + +## Release notes + +The changelog is archival; a release page answers why anyone should care about +*this* release. It is the changelog plus prioritization. + +Lead with one paragraph: what is shipping, who should care, and whether +upgrading is safe. Then highlights, then breaking changes, then the full list. +Do not pretend every patch is a product launch — for a patch, one paragraph +and a `Fixed` list is the whole thing. + +Every claim carries evidence. "Serialization allocates one fewer intermediate +buffer per command" is a claim; "significantly faster" is not. A performance +number names the benchmark, the tmux version, the host, and the date, and is +stated as a ratio or a marginal cost, because absolute milliseconds move by a +factor of five between machines. + +This project has published no release pages yet, so this section is a bar to +meet rather than a description of what exists. + +## Commit messages + +``` +Scope(type[detail]): concise description + +why: Explanation of necessity or impact. + +what: +- Specific technical changes made +- Focused on a single topic +``` + +Keep the subject to 72 characters or fewer, excluding any trailing `(#NN)` +pull request reference; 50 or fewer is better and most of the history manages +it. Wrap body lines at 72. Separate the `why:` and `what:` blocks with a blank +line. No emoji, anywhere. + +Common types: + +- **feat**: New features or enhancements +- **fix**: Bug fixes +- **refactor**: Code restructuring without functional change +- **docs**: Documentation updates +- **chore**: Maintenance (dependencies, tooling, config) +- **test**: Test-related updates +- **style**: Code style and formatting +- **dotnet(deps)**: Dependencies +- **dotnet(deps[dev])**: Dev dependencies +- **ai(rules[AGENTS])**: AI rule updates + +Example: + +``` +Pane(feat[SendKeys]): Add support for a literal flag + +why: Send characters without tmux interpreting them. + +what: +- Add a Literal property to SendKeysRequest +- Pass -l when it is set +``` + +The body explains why this implementation exists. The diff already says which +statements changed, so a body that restates the diff carries nothing. + +Conventional Commits (`feat:`, `fix!:`, `BREAKING CHANGE:` footers) are +deliberately not used here. The format above predates them in this repository, +every commit follows it, and no tooling consumes the Conventional form. Do not +introduce it. + +Use a heredoc so the formatting survives the shell: + +```console +$ git commit -m "$(cat <<'EOF' +Scope(feat[detail]): Concise description + +why: Explanation of the change. + +what: +- First change +- Second change +EOF +)" +``` + +### Release commits + +Never create tags. Never push tags. The owner handles tagging and tag pushes, +because a tag matching `v*` triggers the publish workflow. + +A release commit subject is plain and short: `Tag v`. The detailed +why and what go in the body. Do not use the `Scope(type[detail]):` format for a +release — it buries the lede. + +## API documentation + +Every public member carries XML documentation. `CS1591` is unsuppressed in all +four shipped projects and `TreatWarningsAsErrors` is on, so a missing comment +is a build error rather than a warning. + +`` is one sentence on one line. Start with a verb: `Gets` for a +property, an active present-tense verb for a method — `Reads`, `Runs`, +`Returns`, `Creates`, `Sends` — and `Represents`, `Provides`, or `Describes` +for a type. + +```csharp +/// Reads one option. +``` + +Do not restate the identifier. "Gets the timeout" says nothing the signature +did not; "Gets the maximum time allowed for each attempt before it is +canceled" says what the type cannot. + +`` carries everything longer, in `` blocks. It is a separate +tag, never folded into ``. This is where the facts that make a +library trustworthy live — thread safety, ownership and disposal, cancellation +semantics, ordering, lifetime, and the tmux quirk behind a design: + +```csharp +/// +/// tmux answers commands in the order it received them, so this is safe to +/// call concurrently: each caller gets its own answer rather than someone +/// else's. Cancelling stops the wait, not the command; tmux has already +/// been told. +/// +``` + +`` names the condition, not the type. "More than one of direction, +explicit size, and mode is set" is useful; "Thrown when the request is +invalid" is not. Document every exception a caller can reasonably hit. + +Use the semantic tags rather than formatting by hand: ``, +``, `null`, ``. They flow through +IntelliSense and the generated reference; hand-formatting does not. + +`` is bare, with no `cref`, and only where inheritance genuinely +means identical semantics — `Equals`, `GetHashCode`, `Dispose`, an interface +implementation. Do not use it to avoid documenting a subtly different override. + +`` and `` appear exactly once in this codebase, on +`LibTmuxException`, where the catch pattern is non-obvious enough to earn a +runnable snippet. That is the bar. Do not add them by default — a summary and +remarks that state the contract are what this library relies on, and README +and `docs/` carry the worked examples. + +## Source comments + +A comment ships only if it passes all three gates. Fail any: delete or +rewrite. Borderline: delete — borderline means the information is +reconstructible, which is what makes deletion cheap. + +**Loss.** Three years from now, would losing this cost a maintainer real time +rediscovering intent, an invariant, a constraint, or a failure mode the code +and tests do not already make obvious? + +**Elite.** Would SQLite, Redis, the Go standard library, or CPython write this +comment, at this length? Those projects state the constraint and stop. They do +not argue with an imagined objector. + +**Upkeep.** Will it stay true without maintenance? A comment that hand-syncs a +value the code owns — a count, an offset, a line reference, a duplicated +constant — is false the first time that value moves. + +### Ceiling + +Two or three lines is the working norm here, and four is the ceiling. A +comment reaching four is either carrying several facts, in which case split +it, or arguing, in which case cut it to the fact. + +Rationale, alternatives weighed, and the story of how the code got here belong +in the commit message: timestamped, attached to the exact diff, and free to +maintain. + +A comment often holds both a constraint and the deliberation that found it. +Keep the constraint, cut the deliberation. "Runs at most once per second" +survives; "this is the right trade for now" does not. + +### Keep + +- Why over how: upstream quirks, protocol and compatibility constraints, + performance tradeoffs still part of the contract. +- Invariants, preconditions, ordering, lifetime, and concurrency requirements + that types and tests cannot express. +- Code that looks wrong but is not, so a later cleanup does not reintroduce + the bug. +- A high-level sketch of an algorithm whose local operations do not reveal the + whole. + +### Delete + +- Narration of the next lines; code translated into English. +- Restated names, types, defaults, or control flow. +- Values duplicated from the code and hand-synced. +- Justification, hedging, or apology for a choice. +- Speculation about future requirements. +- History version control already holds, including commented-out code. +- Ticket and issue numbers. They say nothing to a reader without tracker + access, and they rot when the tracker moves. Unfinished work goes in the + tracker, not the source. +- Transient observations — "currently", "for now", "the latest release" — + that go stale with no nearby edit. + +### The upkeep gate in practice + +It reaches values that track our own code. It does not reach frozen external +facts. + +Bad (Delete): + +```csharp +// There are 321 tests to complete for servers. +``` + +Good (Keep): + +```csharp +// tmux < 3.2 reports the pane ID only after the command completes, +// so this query must stay separate. +``` + +### Documentation exception + +Minimal usage examples, and ``, ``, and `` lines on +public API are exempt from the loss gate — they serve the caller, not the +maintainer. They are exempt from nothing else. Ceiling: a good man page entry. + +## Terminology and capitalization + +`tmux` is always lowercase, including at the start of a sentence. Recast +nothing to avoid it. + +`libtmux` names the product, the GitHub organisation, the command-line tool, +and the Python library this one ports. `LibTmux` names the C# package, +namespace, and type. The two are never swapped. + +One noun per concept, everywhere: server, session, window, pane, client, +option, hook, buffer. If it is a pane, it is not also a view, a region, or a +split. Synonym rotation costs a reader precision and costs grep, search, and +an agent a match. + +## Markdown + +Wrap prose at 80 columns. Badges, table rows, fenced code, and a line +dominated by a single URL are exempt — breaking those hurts more than the +column costs. + +No GitHub alert blocks. `> [!NOTE]`, `> [!WARNING]` and the rest render as +literal text everywhere except GitHub, and this project's README is rendered +by nuget.org, which is where most readers meet it. A plain blockquote, or just +a sentence, renders everywhere. + +Tables, badges, and links are fine. + +Never wrap a pull request or issue body. GitHub renders a single newline as a +space inside a file and as a line break inside a comment, so a hand-wrapped +comment arrives as ragged stubs. + +## Code blocks + +Code blocks are paste-and-run units: pasting one block runs exactly one +intended action. Executed examples are exempt — the test suite runs them, +nobody pastes them. + +- **One command per block.** Multiple steps may share a block only when + explicitly chained with `&&`, `;`, or `\` continuations — the chain is then + one logical command. +- **Explanations go in prose above the block**, never as `#` comments inside + it. +- **Command menus are per-command blocks with prose lead-ins**, not tables. +- **Shell commands use the `console` tag with a `$ ` prefix.** This separates + interactive commands from scripts and enables prompt-aware copy. +- **Split long commands with `\`** — one flag or flag+value pair per indented + continuation line, positional arguments last. + +Good: + +Show the last ten commits as a graph: + +```console +$ git log \ + --max-count=10 \ + --graph \ + --oneline +``` + +Bad: + +```console +# Show the last ten commits as a graph +$ git log --max-count=10 --graph --oneline +``` + +### C# blocks are tests + +Every C# block in a shipped document is compiled against the real assemblies, +and a block tagged ```` ```csharp run ```` is additionally executed against a +tmux server of its own. `ReadmeExampleTests` does both, across the root README, +each package README, and `docs/modes/`. An example is either true or a failing +test, which is what stops one that cannot work from being rendered on a package +page. + +So tag a block `csharp run` when it is meant to execute, and leave it plain +`csharp` when it only illustrates. A plain block still has to compile. + +Write examples accordingly. The harness supplies a preamble and hoists type +declarations, but everything else has to be real: no `// setup omitted`, no +method that does not exist, no magic constant the reader cannot resolve. + +Decision records under `docs/decisions/` are deliberately outside this. They +quote what was run at the time, and an example edited later to keep compiling +records nothing. + +### Anchored snippets + +Anchoring is a second guarantee on top of compilation, and it catches a +different failure: prose drifting from the code it was copied from. + +A block wrapped in `` and `` is +materialized from a `#region` of the same name in +`examples/LibTmux.Examples/Snippets/`, so the document holds a verbatim copy of +code that runs. The copy is materialized rather than transcluded because these +are package READMEs and nuget.org renders the Markdown it is given without +resolving anything. + +The loop runs one way: edit the example, then bring the copy across. Never edit +the block in the document. + +```console +$ uv run python eng/docs/sync_snippets.py +``` + +Prefer anchoring for an example a reader is likely to copy verbatim. A block +that only demonstrates a call shape does not need it. + +[`examples/README.md`](../examples/README.md) has the mechanism in full — the +region markers, the `usings:` option, what each check fails on, and how to add +an example. + +## Error messages + +An exception message is a complete sentence: capitalized, period-terminated, +and naming the offending value. Where the caller could plausibly have gotten +it right, say what would have worked. + +```csharp +throw new McpException($"No session '{trimmed}' exists. These do: {known}."); +``` + +Make failure modes searchable. "`ConnectAsync` throws `TmuxNotRunningException` +when no server is listening on the socket" lets a reader find the explanation +from either the method or the exception; "connection can fail" does not. + +## Slop prevention + +Treat AI slop as review-hostile noise, not as proof that text or code is +wrong. The goal is to maximise information density. + +- **AI signatures.** No "Generated by", no conversational filler, no + unexplained emoji, no tool metadata. +- **Brittle references.** No hard-coded line numbers, fragile file counts, + dated "as of" claims, bare SHAs, or local absolute paths — unless they are + strict evidentiary artefacts such as a benchmark log. +- **Diff narration.** Do not restate what moved, was renamed, or was removed + in anything the reader holds alongside the diff: code, XML documentation, + README, or a pull request description. +- **Branch-internal narrative.** Do not mention intermediate states, abandoned + approaches, or "no longer" behaviour unless users of a published release + actually experienced the old state. +- **Low-value scaffolding.** No ownerless TODOs, unused future-proofing, debug + artefacts, or defensive wrappers around failure modes nothing can reach. +- **Prose inflation.** The diction table under [Voice](#voice) governs. +- **Coded labels.** Write rules and findings as plain imperatives. No `[R1]`, + `Option B`, or any index a reader has to decode. + +Preserve the why. Never delete a comment documenting an invariant, a protocol +constraint, a platform quirk, or an upstream workaround — those are the facts +[Source comments](#source-comments) keeps, and every other comment is judged +by it. diff --git a/AGENTS.md b/AGENTS.md index cdbb693..4bf1226 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,356 +1,36 @@ -# AGENTS.md - -Guidance for AI agents working on LibTmux, a .NET client for tmux. - -## Own your tmux sockets - -Several libtmux ports live on this machine and run real tmux at the same time. -A socket in the default root is reachable by all of them, so one port's cleanup -sweep kills another port's servers mid-run — and the failure surfaces in -whichever suite noticed first, which is rarely the one that caused it. That -misattribution is what turns socket sharing into a debugging loop. - -Give this repository a socket root of its own, named for the port and what it -is for: - -- Tests: `TMUX_TMPDIR=/tmp/libtmux-dotnet-test` -- Servers you start by hand: `TMUX_TMPDIR=/tmp/libtmux-dotnet-dev` - -tmux reads `TMUX_TMPDIR` when it execs and puts a `-L name` socket in -`$TMUX_TMPDIR/tmux-$UID/name`, so exporting it before the run is enough. A -`-S path` socket ignores it, and needs a path under the root instead. - -Two things are never safe here, because the processes and directories belong to -other workspaces: - -- `pkill tmux`, or any kill by a pattern matching more than your own root -- deleting `/tmp/tmux-$UID/` or another port's root - -To find what this repository left behind, list its root rather than matching -process names. A socket file outlives the server that made it, so read the -listing as candidates and confirm each with `has-session`: - -```console -$ ls /tmp/libtmux-dotnet-test/tmux-$(id -u) -``` - -## The toolchain is not on `PATH` - -`dotnet` is pinned by `global.json` and resolves through mise: - -```console -$ mise exec -- dotnet build LibTmux.slnx --configuration Release --warnaserror -``` - -## What gates this repository - -`.github/workflows/dotnet.yml` is the source of truth, and its `gate` job is -the single name branch protection requires — adding a job means adding it to -`gate`'s `needs`, not to a protection rule. Beyond building and `dotnet test`, -two validators run on documents rather than the build, and are easy to forget -locally: - -```console -$ uv run python eng/parity/verify_public_api.py -``` - -```console -$ uv run python eng/parity/verify_capabilities.py -``` - -The packaging tests inside the integration suite read what a pack produced, so -they fail on a tree nobody packed. Run the build workflow's order — pack, then -the package consumer, then the ahead-of-time publish — before the integration -suite, or expect `PackageClosureTests` to fail for a reason that is not a bug. - -Publishing ahead of time names a runtime identifier, and restore then writes -one into the lock file of every project in that graph — including the library's, -where the section is empty because no package resolves differently. That is why -`src/LibTmux` and `src/LibTmux.Generators` declare `RuntimeIdentifiers`: without -it, the lock files disagree with the projects and the *next* -`restore --locked-mode` fails with NU1004, which reads like a dependency problem -and is not one. Adding a platform to the matrix means adding its identifier -there and regenerating: - -```console -$ mise exec -- dotnet restore LibTmux.slnx --force-evaluate -``` - -`.github/workflows/dotnet-tmux.yml` builds each supported tmux from source and -runs the integration suite against it, behind a `compatibility` job that plays -the same role as `gate`. That is what proves the compatibility range; the build -workflow only ever sees whatever tmux Ubuntu ships. - -`dotnet.yml` also carries an advisory `macos arm64` lane, because the -compatibility claim names macOS and a claim nobody runs is a claim. Its first -run failed 15 of 854 integration tests; the last of those is fixed and the lane -is green, so what keeps it outside `gate` is now a choice rather than an -outstanding diagnosis. It restores without `--locked-mode`: the lock files are -generated for the Linux runtime identifiers this repository publishes, so -locking a macOS restore would fail for a reason that is not a dependency -problem. - -Every one of those failures was a difference in what the platform put on the -screen rather than in what tmux did. The last two were a runner hostname 61 -characters long: bash's prompt then fills 78 of the pane's 80 columns, and tmux -stores the wrap as a real line break, so a capture that does not ask for -`-J` returns typed text split across two lines. Assertions about text a user -typed capture with `joinWrappedLines`. - -Two more workflows run on a schedule rather than on the gate, because what they -check can change without a commit: `codeql.yml` analyses the build, and -`scorecard.yml` scores the repository's supply chain. Every action reference in -this repository is pinned to a commit SHA with the version in a trailing -comment, which is what stops a moved tag from changing what CI runs. Dependabot -maintains those pins; a pin nobody updates is just a stale action. - -## Testing the MCP server means running a real agent - -`src/LibTmux.Mcp` is a stdio server, so the only honest test of its tool -descriptions is whether a model picks the right tool without being told which. -`eng/mcp/mcp_swap.py` points every installed agent CLI at a local build and -`revert` puts their configs back from the timestamped backup it took: - -```console -$ uv run eng/mcp/mcp_swap.py use --source release --env TMUX_TMPDIR=/tmp/libtmux-dotnet-dev -``` - -Pass `--env TMUX_TMPDIR=...` whenever the sockets under test are not in the -default root. An agent spawns the server with its own environment, so a socket -this shell can see is one the server cannot. - -That same gap is why the swap writes `DOTNET_ROOT` into each config. A -framework-dependent apphost finds its runtime through `DOTNET_ROOT` or `PATH`, -mise puts the SDK in neither, and the failure is silent from the agent's side: -the binary exits before the handshake and the agent reports only that the -server has no tools. - -The tool reference is generated rather than written, so it cannot describe a -surface that is not there. Regenerate it whenever the tool surface changes: - -```console -$ uv run eng/mcp/dump_tools.py -``` - -A wait takes a control-mode client, which is a real attached client: it shows -up in the user's `list-clients` for as long as the wait runs. It attaches with -`ignore-size` so it never drags the window down to its own size, and the watch -is reference counted per session so it exists only while a wait does. Changing -either of those changes what a user sees on their own screen. - -## The Python original is a separate checkout - -This repository was imported out of a monorepo that also held Python libtmux, -so anything grounded in that source needs to be told where it went now: - -```console -$ LIBTMUX_PYTHON_REPOSITORY=~/work/python/libtmux uv run python eng/parity/verify_ledger.py -``` - -## Recorded evidence is a release artifact - -A capability row is `pending` until a matrix run records evidence for it, and -`verified` after. What `verified` claims is exact: these tmux versions, on these -frameworks, at *this tree* — the fingerprint covers every tracked file outside -the evidence directory. Any commit changes it, so a verified row is true at one -commit and stale at the next. - -That is why recording belongs at a release boundary rather than in the gate, -and why `reconcile_versions.py` and `verify_ledger.py` are not in -`.github/workflows/dotnet.yml`. Between releases every row is `pending`, which -is the honest state: nobody has run the matrix against this tree. - -To record, on the commit being released: - -```console -$ eng/tmux/run-matrix.sh --evidence-dir docs/parity/evidence/0001 --capability-cohort 0001 tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj -``` - -```console -$ uv run python eng/parity/reconcile_versions.py --evidence docs/parity/evidence/0001/results.ndjson --write -``` - -Then commit the bundle and the rewritten `version-deltas.json` together, because -the fingerprint is of the tree that commit produces. A tmux build takes about -forty seconds here and the matrix runs the suite fourteen times, so budget half -an hour. - -## Comments earn their maintenance cost - -A comment ships only if it passes all three gates. Fail any: delete or rewrite. -Borderline: delete — borderline means the information is reconstructible, which -is what makes deletion cheap. - -**Loss.** Three years from now, would losing this cost a maintainer real time -rediscovering intent, an invariant, a constraint, or a failure mode the code and -tests do not already make obvious? - -**Elite.** Would SQLite, Redis, the Go standard library, or CPython write this -comment, at this length? Those projects state the constraint and stop. They do -not argue with an imagined objector. - -**Upkeep.** Will it stay true without maintenance? A comment that hand-syncs a -value the code owns — a count, an offset, a line reference, a duplicated -constant — is false the first time that value moves. - -### Ceiling - -One or two lines. A comment reaching four is either carrying several facts, in -which case split it, or arguing, in which case cut it to the fact. - -Rationale, alternatives weighed, and the story of how the code got here belong -in the commit message: timestamped, attached to the exact diff, and free to -maintain. - -A comment often holds both a constraint and the deliberation that found it. Keep -the constraint, cut the deliberation. "Runs at most once per second" survives; -"this is the right trade for now" does not. - -### Keep - -- Why over how: upstream quirks, protocol and compatibility constraints, - performance tradeoffs still part of the contract. -- Invariants, preconditions, ordering, lifetime, and concurrency requirements - that types and tests cannot express. -- Code that looks wrong but is not, so a later cleanup does not reintroduce the - bug. -- A high-level sketch of an algorithm whose local operations do not reveal the - whole. - -### Delete - -- Narration of the next lines; code translated into English. -- Restated names, types, defaults, or control flow. -- Values duplicated from the code and hand-synced. -- Justification, hedging, or apology for a choice. -- Speculation about future requirements. -- History version control already holds, including commented-out code. -- Ticket and issue numbers. They say nothing to a reader without tracker access, - and they rot when the tracker moves. Unfinished work goes in the tracker, not - the source. -- Transient observations — "currently", "for now", "the latest release" — - that go stale with no nearby edit. - -### The upkeep gate in practice - -It reaches values that track our own code. It does not reach frozen external -facts. - -Bad (Delete): - -```csharp -// There are 321 tests to complete for servers. -``` - -Good (Keep): - -```csharp -// tmux < 3.2 reports the pane ID only after the command completes, -// so this query must stay separate. -``` - -### Documentation exception - -Doctests, minimal usage examples, and param, return, and raises lines on public -API are exempt from the loss gate — they serve the caller, not the maintainer. -They are exempt from nothing else. Ceiling: a good man page entry. - -XML documentation — ``, ``, `` — falls under this -exception; `CS1591` is unsuppressed in the published projects. - -## Git Commit Standards - -Format commit messages as: -``` -Scope(type[detail]): concise description - -why: Explanation of necessity or impact. - -what: -- Specific technical changes made -- Focused on a single topic -``` - -Keep the subject ≤50 chars (excluding any trailing `(#NN)` PR ref); wrap -body lines at ≤72 chars. Separate the `why:` and `what:` blocks with a -blank line. - -Common commit types: -- **feat**: New features or enhancements -- **fix**: Bug fixes -- **refactor**: Code restructuring without functional change -- **docs**: Documentation updates -- **chore**: Maintenance (dependencies, tooling, config) -- **test**: Test-related updates -- **style**: Code style and formatting -- **dotnet(deps)**: Dependencies -- **dotnet(deps[dev])**: Dev Dependencies -- **ai(rules[AGENTS])**: AI rule updates - -Example: -``` -Pane(feat[SendKeys]): Add support for a literal flag - -why: Send characters without tmux interpreting them. - -what: -- Add a Literal property to SendKeysOptions -- Pass -l when it is set -``` - -### Release commits - -Never create tags. Never push tags. The user handles tagging and tag -pushes (tags trigger the CI publish workflow). - -Release commit subjects are plain and short: `Tag v`. Put -the detailed why/what in the commit body. Don't use the -`Scope(type[detail]):` format for releases — don't bury the lede. - -For multi-line commits, use heredoc to preserve formatting: -```bash -git commit -m "$(cat <<'EOF' -Scope(feat[detail]): Concise description - -why: Explanation of the change. - -what: -- First change -- Second change -EOF -)" -``` - -## Code Blocks - -Code blocks are paste-and-run units: pasting one block runs exactly one -intended action. Doctests and other executed examples are exempt — the test -suite runs them, nobody pastes them. - -- **One command per block.** Multiple steps may share a block only when - explicitly chained with `&&`, `;`, or `\` continuations — the chain is - then one logical command. -- **Explanations go in prose above the block**, never as `#` comments inside it. -- **Command menus are per-command blocks with prose lead-ins**, not tables. -- **Shell commands use the `console` tag with a `$ ` prefix.** This separates - interactive commands from scripts and enables prompt-aware copy. -- **Split long commands with `\`** — one flag or flag+value pair per indented - continuation line, positional arguments last. - -Good: - -Show the last ten commits as a graph: - -```console -$ git log \ - --max-count=10 \ - --graph \ - --oneline -``` - -Bad: - -```console -# Show the last ten commits as a graph -$ git log --max-count=10 --graph --oneline -``` +# Agent instructions + +Follow the existing project conventions and keep changes narrowly scoped to +what was asked for. + +## Change discipline + +These apply to every change, whatever it touches: + +- Make the smallest coherent change that solves the verified problem. Keep + unrelated cleanup out of it. +- Reuse an existing file, helper, API, or test before adding a new one. +- Keep a new type or member internal until a caller outside the assembly needs + it. A public surface is a promise, and this one is checked by five separate + gates. +- Add a file only for a durable boundary — a distinct responsibility or + independent reuse — not for a single-use helper or a one-line re-export. +- A passing gate is evidence only once it has been shown capable of failing. + Pair a new test with a deliberate break that proves it bites. + +## Which policy applies + +This file routes; it does not restate. Read the one that governs the change +being made: + +- For changes to documentation or user-facing prose — `README.md`, + `CHANGELOG.md`, release notes, commit messages, CLI and help text, error + messages, XML documentation, or source comments — follow + [`.github/WRITING.md`](.github/WRITING.md). +- For building, testing, the gates, pull requests, and releases, follow + [`.github/CONTRIBUTING.md`](.github/CONTRIBUTING.md). +- For a security-sensitive change, or to report a vulnerability, follow + [`SECURITY.md`](SECURITY.md). + +Each is the single home for its subject. Where a rule appears to be stated +twice, the file listed above governs. diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc24e8..ef00066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -221,6 +221,9 @@ it is: a published version can never be deleted from nuget.org, only unlisted. - `LibTmux.Workspace` — sessions from tmuxp workspace files. - `LibTmux.Mcp` — a Model Context Protocol server, installed as a .NET tool. +[0.0.0-alpha.7]: https://github.com/libtmux/libtmux-dotnet/releases/tag/v0.0.0-alpha.7 +[0.0.0-alpha.6]: https://github.com/libtmux/libtmux-dotnet/releases/tag/v0.0.0-alpha.6 +[0.0.0-alpha.5]: https://github.com/libtmux/libtmux-dotnet/releases/tag/v0.0.0-alpha.5 [0.0.0-alpha.4]: https://github.com/libtmux/libtmux-dotnet/releases/tag/v0.0.0-alpha.4 [0.0.0-alpha.3]: https://github.com/libtmux/libtmux-dotnet/releases/tag/v0.0.0-alpha.3 [0.0.0-alpha.2]: https://github.com/libtmux/libtmux-dotnet/releases/tag/v0.0.0-alpha.2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 0535647..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,91 +0,0 @@ -# Contributing - -## What you need - -`dotnet` is pinned by `global.json` and resolves through [mise](https://mise.jdx.dev), -so it is not on `PATH`: - -```console -$ mise exec -- dotnet build LibTmux.slnx --configuration Release --warnaserror -``` - -The validators are Python and run through [uv](https://docs.astral.sh/uv/). You -also need a real `tmux` — the suite drives one rather than mocking it. - -## Give your tmux a socket root of its own - -Tests set `TMUX_TMPDIR=/tmp/libtmux-dotnet-test` for themselves. If you start a -server by hand, put it under `/tmp/libtmux-dotnet-dev`. - -This matters more than it looks. Several libtmux ports run real tmux on one -machine, and a socket in the default root is reachable by all of them, so a -cleanup sweep can kill another project's servers mid-run — and the failure -surfaces in whichever suite noticed, not the one that caused it. - -Never `pkill tmux`, and never delete `/tmp/tmux-$UID/`. To find what this -repository left behind, list its own root: - -```console -$ ls /tmp/libtmux-dotnet-test/tmux-$(id -u) -``` - -A socket file outlives the server that made it, so confirm each with -`has-session` before deciding it is alive. - -## Running what CI runs - -`.github/workflows/dotnet.yml` is the source of truth. Two things are easy to -miss locally, because they check documents rather than the build: - -```console -$ uv run python eng/parity/verify_public_api.py -``` - -```console -$ uv run python eng/parity/verify_capabilities.py -``` - -The packaging tests read what a pack produced, so run the workflow's order — -pack, then the package consumer, then the ahead-of-time publish — before the -integration suite, or `PackageClosureTests` fails for a reason that is not a bug. - -## What a change is expected to carry - -**A behaviour change needs a test against a real tmux.** This library's job is -being right about tmux, and only tmux can say whether it is. - -**A version-dependent behaviour needs a row in the ledger.** Anything that -differs between 3.2a and 3.7b goes through the capability model, and each -difference names the test that proves it in -[`docs/parity/version-deltas.json`](docs/parity/version-deltas.json). - -**A public API addition needs five edits, and each will tell you.** The Roslyn -analyzer baseline (`PublicAPI.Unshipped.txt`), the type and its members in -`docs/public-api.json`, its values if it is an enum, and its owning component in -`eng/parity/verify_production_plan.py`. They fail independently and by name; -follow the errors. - -**A documented example is compiled, and a `csharp run` block is executed against -a live tmux.** If it does not compile, it is not documentation. Add examples to -the READMEs or `docs/modes/`, not to the decision records — those quote what was -run at the time and are not edited to keep compiling. - -**A performance claim needs a recorded run.** See -[`docs/benchmarks`](docs/benchmarks/README.md). Absolute milliseconds move by a -factor of five on one host, so a claim is stated as a marginal cost or a ratio, -with the tmux, host and date that produced it. - -## Style - -The build is the style guide: `TreatWarningsAsErrors`, `Nullable` enabled, -analyzers at `10-recommended`, and `EnforceCodeStyleInBuild`. If it compiles -clean, the formatting is right. - -Comments explain *why*, not *what*. The surrounding code is the reference for -density and idiom. - -Commit messages say what changed and why it was worth changing. No emojis. - -## Reporting a vulnerability - -Not here — see [SECURITY.md](SECURITY.md). diff --git a/README.md b/README.md index f7b8518..78aff57 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# LibTmux +# libtmux for .NET [![LibTmux](https://img.shields.io/nuget/vpre/LibTmux?logo=nuget&label=LibTmux)](https://www.nuget.org/packages/LibTmux) [![downloads](https://img.shields.io/nuget/dt/LibTmux?logo=nuget&label=downloads)](https://www.nuget.org/packages/LibTmux) @@ -10,10 +10,9 @@ Drive [tmux](https://github.com/tmux/tmux) from .NET. Servers, sessions, windows, panes, clients, options, hooks and buffers, typed and asynchronous, against every tmux from **3.2a to 3.7b** on **net8.0** and **net10.0**. -> **Alpha.** The public API is not settled and can change between prereleases -> without notice, so pin an exact version. The behaviour is proven against all -> seven supported tmux versions on every commit — what is unsettled is the shape -> of the API, not whether it works. +> **Alpha.** Releases carry an `-alpha` prerelease tag. The API is not +> settled, and any release may change or remove exported identifiers without a +> deprecation period. Pin an exact version. Not recommended for production. ```csharp @@ -54,7 +53,7 @@ documented examples that are executed against live tmux in CI. | **[LibTmux.Mcp](src/LibTmux.Mcp/README.md)** | [![v](https://img.shields.io/nuget/vpre/LibTmux.Mcp?logo=nuget&label=%20)](https://www.nuget.org/packages/LibTmux.Mcp) | You want an assistant driving tmux. Installs as a tool, not a reference. | ```console -$ dotnet add package LibTmux --prerelease +$ dotnet package add LibTmux --prerelease ``` The core takes exactly one dependency. Anything that would add another ships as @@ -284,6 +283,7 @@ and a tool above it never reaches the model's list. - [Public API](docs/public-api.md) — the reviewed, approved surface - [Version deltas](docs/parity/version-deltas.json) — every tmux difference, with its proof - [Decisions](docs/decisions/) — why the transport, object model and query catalog are shaped this way +- [Examples](examples/README.md) — every example here is a test, and the C# in this README is quoted from one - [AGENTS.md](AGENTS.md) — how to work in this repository ## Compatibility diff --git a/docs/quality-bar.md b/docs/quality-bar.md index be60fe8..4409d51 100644 --- a/docs/quality-bar.md +++ b/docs/quality-bar.md @@ -16,7 +16,7 @@ and are not defects. Below 9 means something is wrong rather than absent. | Criterion | Evidence | |---|---| -| Install to working code in one screen | `dotnet add package LibTmux --prerelease`, then a seven-line example, both in the first 30 lines of the README | +| Install to working code in one screen | `dotnet package add LibTmux --prerelease`, then a seven-line example, both in the first 30 lines of the README | | One way to do a thing | Async only, no synchronous twins; every tmux-reaching call takes a `CancellationToken` | | The mode you are in is visible at the call | `session.CreateWindowAsync`, `server.EnterControlModeAsync`, `server.Chain()` — never a flag in options | | Failure says what to do next | `TmuxDispatchState` on every exception; the retry decision is an exception filter, [proved in tests](../tests/LibTmux.UnitTests/Exceptions/DispatchStateTests.cs) | diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..102bfff --- /dev/null +++ b/examples/README.md @@ -0,0 +1,112 @@ +# Examples + +Every example here runs against a real tmux server of its own, as a test, on +every build. Nothing in this directory can quietly stop compiling or stop being +true — and because the READMEs quote from it, neither can they. + +Two mechanisms sit on top of that, and they catch different failures: + +| Check | Where | Fails when | +| --- | --- | --- | +| `ReadmeExampleTests` | `tests/LibTmux.IntegrationTests/Documentation/` | A C# block in a shipped document does not compile, or a `csharp run` block does not run | +| `sync_snippets.py` | `eng/docs/` | A published block has drifted from the region it was quoted from | +| `SnippetContractTests` | `tests/LibTmux.ExampleTests/` | A published region is not an example that runs, or an example is somewhere the snippet reader cannot see | + +## Writing an example + +An example is a method carrying `[Example]`, taking a `Server` and a +`CancellationToken`, and returning `Task`. It lives in a class under +[`LibTmux.Examples/Snippets/`](LibTmux.Examples/Snippets/) — one class per +topic, and the class name *is* the topic, so `Chaining.cs` holds the chaining +examples. + +```csharp +/// Runs three commands through a single tmux invocation. +[Example("Three commands, one process")] +public static async Task ManyCommandsOneProcess(Server server, CancellationToken ct) +{ + #region ManyCommandsOneProcess + await server.Chain() + .Then("new-window", "-d", "-n", "build") + .Then("new-window", "-d", "-n", "test") + .ExecuteAsync(ct); + #endregion +} +``` + +Run them all: + +```console +$ mise exec -- dotnet run \ + --project examples/LibTmux.Examples/LibTmux.Examples.csproj \ + --configuration Release +``` + +## Quoting an example in a document + +The `#region` name is what a document publishes, and it matches the method +name. Put an anchor pair where the block belongs and the region is copied +between them: + +```markdown + + +``` + +Then materialize it: + +```console +$ uv run python eng/docs/sync_snippets.py +``` + +The region is dedented before it is written, so code nested inside a method +lands at column zero in the document. A document that needs a `using` the +snippet file already has at file scope can ask for it, and the directive is +written above the block: + +```markdown + +``` + +Nine documents publish snippets: the root README, each of the four package +READMEs, and the four files under `docs/modes/`. Decision records are +deliberately excluded — they quote what was run at the time, and an example +edited later to keep compiling records nothing. + +### The loop runs one way + +Edit the example, run it, then bring the copy across. **Never edit the block in +the document** — `--check` compares the two and fails the build on any +difference, so a hand-edited block is reported as drift rather than kept: + +```console +$ uv run python eng/docs/sync_snippets.py --check +``` + +That is what CI runs. + +### Both failure modes + +`SnippetContractTests` fails a region that no `[Example]` method runs, because +a published block nobody executes is exactly the sample that rots. It also +fails an example that lives outside +[`LibTmux.Examples/Snippets/`](LibTmux.Examples/Snippets/), since that is the +one directory `sync_snippets.py` globs — an example anywhere else is invisible +to it and would never be published at all. + +## Blocks that are not snippets + +A C# block does not have to be a snippet. `ReadmeExampleTests` compiles every +`csharp` block in those same nine documents against the real assemblies, and +executes the ones tagged `csharp run` against a tmux server of their own: + +````markdown +```csharp run +Window built = await session.CreateWindowAsync(new NewWindowRequest(name: "build"), ct); +``` +```` + +Tag a block `csharp run` when it is meant to execute, and leave it plain +`csharp` when it only illustrates. A plain block still has to compile. The +harness supplies a preamble and hoists type declarations, so an example does +not repeat the setup — but everything else has to be real. diff --git a/src/LibTmux.Query.Json/README.md b/src/LibTmux.Query.Json/README.md index 293e413..f6ab916 100644 --- a/src/LibTmux.Query.Json/README.md +++ b/src/LibTmux.Query.Json/README.md @@ -8,7 +8,7 @@ want it does not get it. > without notice, so pin an exact version. ```console -$ dotnet add package LibTmux.Query.Json --prerelease +$ dotnet package add LibTmux.Query.Json --prerelease ``` ## When you want this diff --git a/src/LibTmux.Workspace/README.md b/src/LibTmux.Workspace/README.md index e4123fb..b34bf42 100644 --- a/src/LibTmux.Workspace/README.md +++ b/src/LibTmux.Workspace/README.md @@ -7,7 +7,7 @@ workspace files, on top of [LibTmux](https://www.nuget.org/packages/LibTmux). > without notice, so pin an exact version. ```console -$ dotnet add package LibTmux.Workspace --prerelease +$ dotnet package add LibTmux.Workspace --prerelease ``` Adds one dependency, [YamlDotNet](https://github.com/aaubry/YamlDotNet), which diff --git a/src/LibTmux/README.md b/src/LibTmux/README.md index a732a7e..0acfe48 100644 --- a/src/LibTmux/README.md +++ b/src/LibTmux/README.md @@ -9,7 +9,7 @@ every tmux from **3.2a to 3.7b**, on **net8.0** and **net10.0**. > seven supported tmux versions on every commit. ```console -$ dotnet add package LibTmux --prerelease +$ dotnet package add LibTmux --prerelease ``` One dependency: `Microsoft.Extensions.Logging.Abstractions`, which is