diff --git a/docs/engineering/developing-on-windows.md b/docs/engineering/developing-on-windows.md index 6a84d0bf..af6e62f2 100644 --- a/docs/engineering/developing-on-windows.md +++ b/docs/engineering/developing-on-windows.md @@ -4,8 +4,9 @@ Date: 2026-10-16 Operational guidance for a Windows workstation. Everything here is about the developer's machine; the test suite's own isolation rules live in `testing-style.md` and the gates in `quality-gates.md`. -A fresh worktree is prepared as `quality-gates.md` describes before its first gate; a workstation's -own Bun is often not the pinned one, so the pinned Bun goes first on `PATH`. +A fresh worktree is prepared as `quality-gates.md` describes before its first gate; a workstation +keeps one Bun, and when that is not the pinned one the gate runs with the Bun mismatch switch that +`quality-gates.md` describes. ## Native AOT publishing diff --git a/docs/engineering/quality-gates.md b/docs/engineering/quality-gates.md index ec100c9f..8683cccd 100644 --- a/docs/engineering/quality-gates.md +++ b/docs/engineering/quality-gates.md @@ -126,8 +126,11 @@ It refuses a run filtered to tests that start no server too: every gate and CI l checkout, so an unprepared one is named before it costs a run. One exception is explicit: `OPENCODE_SDK_TESTS_BUN_MISMATCH=allow` lets a gate host whose only Bun -is another version run on it. It is for a host the maintainer has named, never a default, and CI -never sets it; `setup-pinned-server` installs and verifies the exact pin. Such a host departs from +is another version run on it. The maintainer's rule is one Bun per machine, so a local gate host runs +its one Bun with this switch until the pin's `packageManager` names that version, and its result is +local evidence only. The switch is set per command, never as a default, and CI never sets it +(`CiPinnedBunTests`): `setup-pinned-server` installs and verifies the exact pin, so CI is the +exact-pin authority. Such a host departs from the pin twice: its install and its gate both run on its own Bun. The frozen lockfile keeps the installed dependency tree identical to the pin's; the Bun that runs the server is what differs. The variable allows only a different version: a missing Bun, checkout or install is still refused, and @@ -235,7 +238,7 @@ target framework updates it in the same change. The detector ignores using directives; a baseline entry that matches no clone fails the gate like a new clone does. The lint job runs `dotnet run --file tools/opencode-tool.cs -- verify-clones`: the pinned jscpd in -`tools/jscpd/`, installed with the pinned Bun from its own lockfile, at a 50-token minimum over four +`tools/jscpd/`, installed with the machine's Bun from its own lockfile, at a 50-token minimum over four scopes. Hand-written `src/`, `tools/` and `tests/` are gated against the committed baselines in `tools/jscpd/`: a clone a baseline does not hold fails the job, `tools/` also fails above 3% duplicated lines, and `tests/` above 6%. The tests ceiling is the measurement plus 0.25 points, diff --git a/tests/OpenCode.Sdk.Tests/Harness/PinnedServerPrerequisites.cs b/tests/OpenCode.Sdk.Tests/Harness/PinnedServerPrerequisites.cs index 433a7bd0..8a84d7db 100644 --- a/tests/OpenCode.Sdk.Tests/Harness/PinnedServerPrerequisites.cs +++ b/tests/OpenCode.Sdk.Tests/Harness/PinnedServerPrerequisites.cs @@ -113,7 +113,8 @@ internal static async Task FindAsync( { problems.Add( $"The bun on PATH is {bunVersion}, but the pin's packageManager is bun@{pinned.Version} " - + $"('{pinned.PackageJson}'). Put bun {pinned.Version} first on PATH."); + + $"('{pinned.PackageJson}'). A machine keeps one Bun: set {BunMismatchVariable}={AllowBunMismatch} " + + "to run on it as local evidence only."); } } @@ -135,7 +136,7 @@ internal static async Task FindAsync( internal static string? MissingInstall(IFileSystem fileSystem, string submodule) => fileSystem.Directory.Exists(fileSystem.Path.Combine(submodule, "node_modules")) ? null - : $"The pinned server dependencies are not installed under '{submodule}'. Run there, with the pinned bun: bun install --frozen-lockfile --ignore-scripts"; + : $"The pinned server dependencies are not installed under '{submodule}'. Run there: bun install --frozen-lockfile --ignore-scripts"; internal static string Submodule(IFileSystem fileSystem, string repositoryRoot) => fileSystem.Path.Combine(repositoryRoot, "external", "opencode"); diff --git a/tests/OpenCode.Sdk.Tests/Harness/PinnedServerPrerequisitesTests.cs b/tests/OpenCode.Sdk.Tests/Harness/PinnedServerPrerequisitesTests.cs index 79cc0acd..a033e70d 100644 --- a/tests/OpenCode.Sdk.Tests/Harness/PinnedServerPrerequisitesTests.cs +++ b/tests/OpenCode.Sdk.Tests/Harness/PinnedServerPrerequisitesTests.cs @@ -50,6 +50,7 @@ public async Task FindAsync_Should_Name_Both_Versions_When_The_Bun_On_Path_Is_No await Assert.That(readiness.Problems).HasSingleItem(); await Assert.That(readiness.Problems[0]).Contains("The bun on PATH is " + OtherBun); await Assert.That(readiness.Problems[0]).Contains("bun@" + PinnedBun); + await Assert.That(readiness.Problems[0]).Contains("set OPENCODE_SDK_TESTS_BUN_MISMATCH=allow"); await Assert.That(readiness.AllowedMismatch).IsNull(); } diff --git a/tests/OpenCode.Sdk.Tools.Tests/RepositoryGates/CiPinnedBunTests.cs b/tests/OpenCode.Sdk.Tools.Tests/RepositoryGates/CiPinnedBunTests.cs new file mode 100644 index 00000000..616d32e8 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/RepositoryGates/CiPinnedBunTests.cs @@ -0,0 +1,45 @@ +using OpenCode.Sdk.Tools.Tests.Support; +using Testably.Abstractions; + +namespace OpenCode.Sdk.Tools.Tests.RepositoryGates; + +/// +/// CI is the exact-pin authority for the Bun that runs the pinned server: it installs the version +/// the upstream checkout's packageManager names, and it never sets the switch that lets a +/// local gate host run on another Bun (PinnedServerPrerequisites.BunMismatchVariable in +/// OpenCode.Sdk.Tests, whose name these tests repeat because Tools.Tests cannot reference it). +/// +public sealed class CiPinnedBunTests +{ + private const string BunMismatchVariable = "OPENCODE_SDK_TESTS_BUN_MISMATCH"; + private const string PinnedBunInstall = "bun-version-file: external/opencode/package.json"; + + private readonly RealFileSystem _fileSystem = new(); + + [Test] + public async Task Ci_Should_Never_Set_The_Bun_Mismatch_Switch() + { + var root = new RepositoryRoot(_fileSystem).Locate(); + var files = _fileSystem.Directory + .EnumerateFiles(_fileSystem.Path.Combine(root, ".github"), "*", SearchOption.AllDirectories) + .ToList(); + var contents = await Task.WhenAll(files.Select(file => _fileSystem.File.ReadAllTextAsync(file))); + var mentioning = files + .Where((_, index) => contents[index].Contains(BunMismatchVariable, StringComparison.Ordinal)) + .Select(file => _fileSystem.Path.GetRelativePath(root, file)) + .ToList(); + + await Assert.That(files).IsNotEmpty(); + await Assert.That(mentioning).IsEmpty(); + } + + [Test] + public async Task Ci_Should_Install_The_Bun_The_Pinned_Checkout_Names() + { + var root = new RepositoryRoot(_fileSystem).Locate(); + var action = await _fileSystem.File.ReadAllTextAsync( + _fileSystem.Path.Combine(root, ".github", "actions", "setup-pinned-bun", "action.yml")); + + await Assert.That(action).Contains(PinnedBunInstall); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Clones/CloneDetector.cs b/tools/OpenCode.Sdk.Tools/Clones/CloneDetector.cs index db613769..b803edf1 100644 --- a/tools/OpenCode.Sdk.Tools/Clones/CloneDetector.cs +++ b/tools/OpenCode.Sdk.Tools/Clones/CloneDetector.cs @@ -9,7 +9,7 @@ namespace OpenCode.Sdk.Tools.Clones; /// /// Runs the pinned jscpd over one clone scope: it writes the scope's jscpd config into a work -/// folder, runs the detector through the pinned Bun from the repository root, and reads the totals of +/// folder, runs the detector through Bun from the repository root, and reads the totals of /// the JSON report it writes. A gated scope runs against its baseline and fails on a new clone or /// stale baseline entry or above its threshold; with an update it rewrites the baseline instead. Every scope fails when it /// analyses no file. jscpd's own defaults stay: it honours .gitignore, follows no symbolic link, and @@ -34,7 +34,7 @@ public void EnsureInstalled(string root) if (!_fileSystem.File.Exists(_fileSystem.Path.Combine(root, RunnerPath))) { throw new CloneDetectorException( - "The clone detector is not installed. Run once, with the pinned Bun first on PATH: " + "The clone detector is not installed. Run once, with the machine's Bun: " + "cd " + CloneScopes.DetectorFolder + " && bun install --frozen-lockfile --ignore-scripts"); } } diff --git a/tools/jscpd/package.json b/tools/jscpd/package.json index ad6b029e..6f0dcdab 100644 --- a/tools/jscpd/package.json +++ b/tools/jscpd/package.json @@ -1,7 +1,7 @@ { "name": "opencode-sdk-dotnet-clone-detector", "private": true, - "description": "The pinned jscpd that `opencode-tool verify-clones` runs; installed with the pinned Bun from bun.lock.", + "description": "The pinned jscpd that `opencode-tool verify-clones` runs; installed with Bun from bun.lock.", "dependencies": { "jscpd": "5.4.1" }