Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ jobs:
legs: ${{ steps.select.outputs.legs }}
pack: ${{ steps.select.outputs.pack }}
suite: ${{ steps.select.outputs.suite }}
projects: ${{ steps.select.outputs.projects }}

steps:
# The whole history: the diff is against the base branch, which a shallow clone has not fetched.
Expand Down Expand Up @@ -93,13 +94,15 @@ jobs:

# The legs the changes job selected: Linux in every run, Windows when the change can affect it, macOS only in a
# run that selects nothing. A prose-only change builds and runs test/Prose.slnf alone, the test projects that
# read prose (ProseReaderTests holds the set); anything else, the whole solution.
# read prose (ProseReaderTests holds the set). A change to tools, tests, prose or the ignore list builds and runs
# the projects it reaches, written as test/Reach.slnf (design/adr/0127, TestReachTests holds what reaches what);
# anything else, the whole solution.
test:
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: changes
env:
SOLUTION: ${{ needs.changes.outputs.suite == 'prose' && 'test/Prose.slnf' || 'CodoMetis.TypeKit.slnx' }}
SOLUTION: ${{ needs.changes.outputs.suite == 'prose' && 'test/Prose.slnf' || needs.changes.outputs.suite == 'reach' && 'test/Reach.slnf' || 'CodoMetis.TypeKit.slnx' }}
strategy:
fail-fast: false
matrix:
Expand All @@ -117,6 +120,14 @@ jobs:
with:
global-json-file: global.json

# The projects the change reaches, as a solution filter beside test/Prose.slnf. The gate refuses a reached
# suite whose projects are not one or more project files, so a filter of nothing cannot pass.
- name: Write the reached suite
if: needs.changes.outputs.suite == 'reach'
env:
PROJECTS: ${{ needs.changes.outputs.projects }}
run: printf '{"solution":{"path":"../CodoMetis.TypeKit.slnx","projects":%s}}\n' "$PROJECTS" > test/Reach.slnf

- name: Restore
run: dotnet restore "$SOLUTION"

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ dist/

# Test output
TestResults/
# The suite CI writes for a change to tools or tests (design/adr/0127)
/test/Reach.slnf
*.trx
*.coverage
coverage*.xml
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ dotnet tools/ConsumerSmokeTest/bin/Release/net10.0/consumer-smoke-test.dll # t
dotnet tools/BuildVerdict/bin/Release/net10.0/build-verdict.dll test [--project P] [--filter-class C] [--filter-method M] [-- flags] # an agent's build and test: a verdict, not the transcript
dotnet tools/PrHygiene/bin/Release/net10.0/pr-hygiene.dll event.json # a PR's labels, milestone, branch and issue link, as CI's PR hygiene check reads them from the payload
dotnet tools/CiLanes/bin/Release/net10.0/ci-lanes.dll classify <path>... # the CI lanes a changed path selects (Linux, Windows, the pack; macOS only on the release's runs), as CI's changes job decides them
dotnet tools/CiLanes/bin/Release/net10.0/ci-lanes.dll suite <path>... # the tests a changed path runs alone: the whole solution, the prose suite, or the projects it reaches
```

The oracle kit's local tool, which needs no model and never runs in CI
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# 0127. A change to tools or tests runs the test projects it reaches, and the ignore list has a class

Date: 2026-10-31
Status: accepted

Context: 2026-10-31, issue #268, amending the selection of [decision 113](0113-ci-runs-the-lanes-a-change-can-affect.md).
Under 113 a lane builds and tests the whole solution unless every changed path is prose. #266 changed one tool,
`tools/BashGuard`, its tests, docs and one line of `.gitignore`. Its first run, 41215127177, took 12m55s on Linux and
13m32s on Windows, and packed and smoke-tested too, because `.gitignore` was unlisted. The tests that could notice
the change were `CodoMetis.TypeKit.BashGuard.Tests` (112 tests, 5 s locally) and the conventions. A docs-only PR
(#267) runs `test/Prose.slnf` alone, in 1m49s.

## Decision

**A third suite, `reach`.** When every changed path is prose, the ignore list, or a path inside a project of the
solution under `tools/` or `test/`, and at least one is not prose, the legs build and test the projects the change
reaches, not the solution. Any other path runs the whole solution as before: `src/**`, `tools/Shared/**`,
`tools/Directory.Build.*`, `tools/CiLanes/**` and `tools/ConsumerSmokeTest/**` (the smoke test class, which keeps
the pack), `spikes/**`, `test/Directory.Build.props`, `test/oracles/**`, a solution filter, a project the solution
does not list, the build files and every unlisted path. The lanes are 113's: a reached suite runs where the paths'
classes select, so a tool or test change still runs on Windows, with the conventions and the changed tool's tests.

**What a change reaches.** A changed path reaches a solution project under `tools/` or `test/` when the project reads
it. A project reads its own directory; every path an item or an import of its project file names outside that
directory by a relative path (a linked source such as `../CodoMetis.TypeKit.BuildVerdict.Tests/ToolSandbox.cs`, a
`ProjectReference`); and, through each `ProjectReference`, whatever the referenced project reads, so a change to
`test/CodoMetis.TypeKit.Generators.Probes` reaches the four test projects that reference it. A prose path adds the
projects of `test/Prose.slnf`. The suite is every project reached, test projects and changed tools alike, so the
changed tool is built even when no test references it (`tools/Oracles`).

**A read by path is declared.** A test that reads another project at run time, by path rather than by reference,
declares it in its project file with an item ci-lanes reads like any other: `<ReadsByPath Include="../" />`. Two
projects read the repository that way. The conventions read every file (its index, every project's sources and
evaluation) and declare `../../`, the root, so every reached suite runs them. The oracle tests find the test that
holds each claim among every test project's sources and declare `../`, `test/`. No other test project reads another
by path: the tools' tests read their own tool, which they reference, and `tools/Shared`, which runs the whole
solution.

**`TestReachTests` holds the declarations** (conventions). It evaluates every project under `tools/` and `test/` with
MSBuild (items and imports, as `CiLaneTests` does for the packages) and fails a file a test project builds from,
outside its own directory, whose change alone would not run it: the evaluation, not ci-lanes' reading of the project
file, so an include ci-lanes cannot read (one through a property) is found. It reads every test project's string
literals and fails a project named by path (`"tools/PrHygiene/Program.cs"`, or `"PrHygiene"` beside `"tools"`) or a
whole directory scanned (`Path.Combine(root, "test")` in a file that enumerates) that the reader does not reach,
unless the file is waived with the reason it reads nothing. It fails a narrowed suite without the conventions.

**The ignore list is its own class**, `ignore list`, Linux alone, which narrows to the projects that declare they
read the root, the conventions. Measured: no test reads `.gitignore`. The conventions list the index
(`git ls-files`), which the ignore list does not change, and a fresh checkout has nothing to ignore; `dotnet pack`
does not read it. `.gitattributes` stays unlisted: it sets the line endings of every file a Windows checkout holds
and a package packs.

**The selection answers `projects`.** `ci-lanes select` writes `suite=prose|reach|full` and `projects`, a JSON array
of project files, empty unless the suite is `reach`. The test legs write it as `test/Reach.slnf`, a filter of the
solution beside `test/Prose.slnf` (ignored by git), and restore, build and test that. `ci-lanes suite <path>...`
answers the suite each path alone runs, for a contributor and for `TestReachTests`. The gate passes a `reach` only
beside no pack and no macOS leg, with one or more distinct project files under `tools/` or `test/`, since a filter
of nothing builds and tests nothing and passes; it fails `projects` beside the other suites, and a missing or
malformed `projects`. A cancelled or skipped leg of a reached suite fails it as any selected job does.

**Anything ci-lanes cannot read runs the whole solution:** the solution file, a project file, a referenced project
that does not exist, `test/Prose.slnf`, or a path no project reaches.

## Consequences

- A change shaped like #266 runs the prose suite, `tools/BashGuard` and its tests on Linux and Windows, with no pack.
- A change to a test project runs it, the conventions and the oracle tests; a change to a tool, the tool, its tests,
what references them, and the conventions.
- A reach the declarations miss is a test that stops running for the change that breaks it, the failure this
repository calls silent. `TestReachTests` sees the evaluation and the literals; a read built from neither (a path
read from a file, or computed) is found only by review, and the whole solution still runs on the release's pull
request into `main`, the nightly and the release.
- Windows runs a narrowed suite as Linux does: a Windows-only defect in a test project the change does not reach
surfaces on those same runs.
- A path class is changed here, by a new record, and in `tools/CiLanes` together, as under 113.

## Evidence

Measured locally on 2026-10-31 against the checkout: `ci-lanes suite` over #266's eleven paths answers the five
projects of `test/Prose.slnf`, `tools/BashGuard` and `CodoMetis.TypeKit.BashGuard.Tests`, and `test/Reach.slnf`
written from them builds (17 projects with the shipping projects they reference). Each new `SelectionTests` and
`GateTests` row failed against 113's selection first; `TestReachTests` failed with the oracle tests' declaration
removed, the conventions' removed, a read of `tools/PrHygiene` seeded into the BashGuard tests, and ci-lanes made to
skip linked sources and references. The CI timings before and after are recorded on #268.
3 changes: 2 additions & 1 deletion design/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ per area ([design](../plan.md)) link to the records they rest on.
| [0110](0110-catchasync-takes-a-valuetask-at-a-lower-priority.md) | `CatchAsync` has `ValueTask` forms marked `[OverloadResolutionPriority(-1)]`, so an `async` lambda binds to the `Task` form as before (measured); `Catch` refuses a `ValueTask` at run time still; `TraverseAsync` on `Option<T>` and `Patch<T>` awaits the one operation, and calls nothing and checks no token without a value | 2026-10-28 | accepted |
| [0111](0111-a-mapping-can-withhold-the-fault-name.md) | A mapping withholds a member's name with `FaultResponse.WithoutFaultName()`: its problem has no `fault`, no `rule` and no member description as its detail; `IFaultFeature.NameWithheld` tells the host; a response whose every fault withholds its name is described as naming none; the same-name warning counts only members that write their name | 2026-10-28 | accepted |
| [0112](0112-the-prober-gives-way-to-valueranges-oracle-kit.md) | The prober, its corpus, its seeds, the CI replay and the release gate are retired for CodoMetis.ValueRanges' oracle kit under `test/oracles/`: claims quoted verbatim and held by named tests, every public member claimed or waived, oracles as ordinary test classes each with a seed CI checks applies, candidates, and a hand-written axes file; `tools/Oracles seeds` builds one project and runs one class per seed | 2026-10-28 | accepted |
| [0113](0113-ci-runs-the-lanes-a-change-can-affect.md) | CI runs the lanes a change can affect: each changed path selects Linux, Windows and the pack by its class in `tools/CiLanes` (Windows for the analyzers, the generators, tests and tools; the pack for the packages), a path no class names selects all three, a prose-only change runs only the test projects that read prose (`test/Prose.slnf`), and only a pull request into `main`, a push to `main`, the nightly and the release run macOS; a gate job, `CI`, over every job is the required check; a convention test refuses non-portable file names; `release.yml` calls the build workflow before it verifies | 2026-10-29 | accepted |
| [0113](0113-ci-runs-the-lanes-a-change-can-affect.md) | CI runs the lanes a change can affect: each changed path selects Linux, Windows and the pack by its class in `tools/CiLanes` (Windows for the analyzers, the generators, tests and tools; the pack for the packages), a path no class names selects all three, a prose-only change runs only the test projects that read prose (`test/Prose.slnf`), and only a pull request into `main`, a push to `main`, the nightly and the release run macOS; a gate job, `CI`, over every job is the required check; a convention test refuses non-portable file names; `release.yml` calls the build workflow before it verifies | 2026-10-29 | accepted; amended by [0127](0127-a-change-to-tools-or-tests-runs-the-test-projects-it-reaches.md) |
| [0114](0114-a-value-object-s-temporary-number-is-its-wrapped-type-s.md) | A value object over a number gets the temporary values a property of its wrapped type gets, from a factory in its converter's mapping hints, which EF consults after a generator on the property and after HiLo: a default `ulong` key no longer throws at `Add`, and a `char` key configured on add gets a temporary `char` on SQL Server; under `--nativeaot` the factory is lost and a default `ulong` key still throws | 2026-10-29 | accepted |
| [0115](0115-value-objects-can-be-inlined-everywhere.md) | `AddTypeKit(typeKit => typeKit.InlineValueObjects = true)` writes every value object in place, by its wrapped type's schema, in every position and version, with no component of its own; off by default, the document unchanged; closed sets and wrapped enums keep their components; an inlined value object claims no name (decision 55) and shares no schema between its JSON positions and its parameters (decision 106) | 2026-10-29 | accepted |
| [0116](0116-the-docs-site-is-docfx-built-from-main.md) | The docs site is DocFX, published to GitHub Pages from `main` alone: the guides, the READMEs, the changelog and the API pages of the run-time packages, from the files as they are; a link to a file that is not a page becomes a GitHub address at the built commit; a pull request touching a page builds it with warnings as errors; `site/**` is prose; DocFX 2.81.0 lists the members of C# 14 extension blocks as extension methods | 2026-10-29 | accepted |
Expand All @@ -133,3 +133,4 @@ per area ([design](../plan.md)) link to the records they rest on.
| [0124](0124-an-operator-in-a-query-is-the-wrapped-type-s-arithmetic.md) | In an EF Core query, every generated arithmetic operator is rewritten to the wrapped type's operator over `ProviderValue`, which translates only through TypeKit's converter, its value-object result `Materialize`d and re-typed, never cast; `byte`, `short` and `char` operators are refused; refines decision 122, on decision 123's mark | 2026-10-30 | accepted |
| [0125](0125-a-package-documents-the-third-party-code-it-contains.md) | A package carries a `THIRD-PARTY-NOTICES.txt` only when it contains third-party code (a bundled assembly, copied source, a binary release), built from the shipping closure with the license texts; a package that bundles nothing documents its own license, copyright and dependency list, and its dependencies' licenses are held to an allow-list by a conventions test, not listed (#181); no SBOM is shipped, since GitHub's export is an inventory without license data | 2026-10-30 | open |
| [0126](0126-parallel-work-runs-in-worktrunk-worktrees-beside-the-repository.md) | Parallel work runs in worktrunk worktrees at `../CodoMetis.TypeKit-worktrees/<branch>`, set per machine in worktrunk's user config; worktrunk's default branch is the open `release/<version>`, set per clone, so `wt switch -c`, the plugin's agent worktrees, `wt remove` and `wt step prune` base on it; the Bash guard refuses `wt merge` and `wt step squash`, `rebase` and `push`, since work lands by a PR and a merge commit (decision 94); nothing is copied into a new worktree, which builds the solution cold in 27 s; `.claude/worktrees/` joins `.gitignore`; [design/worktrees.md](../worktrees.md) is the runbook | 2026-10-31 | accepted |
| [0127](0127-a-change-to-tools-or-tests-runs-the-test-projects-it-reaches.md) | A change whose paths are prose, the ignore list or inside solution projects under `tools/` and `test/` runs the projects it reaches (`suite=reach`, written as `test/Reach.slnf`): a project reaches its directory, what its items and imports name and what its references reach, and a run-time read by path is declared with `<ReadsByPath>` (the conventions the root, the oracle tests `test/`), which `TestReachTests` holds against MSBuild's evaluation and the sources' literals; anything else, or anything unreadable, runs the whole solution; `.gitignore` is Linux alone; the gate refuses a reached suite with no projects, the pack or macOS; amends decision 113 | 2026-10-31 | accepted |
Loading
Loading